diff --git a/corpus/page-lifecycle/creation/README.md b/corpus/page-lifecycle/creation/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..fa19f5425ee7f7d144d312df11408a4f36baec8b
--- /dev/null
+++ b/corpus/page-lifecycle/creation/README.md
@@ -0,0 +1,37 @@
+# Page creation
+
+`candidate` adds four pages to `../03-renamed/notebook/Lifecycle.one`: an appended
+page without a title node, a prepended page with an empty title field, and two
+appended pages titled `New 🦋 é`. All thirteen ordered page identities survive
+each fresh OneNote 2010 reopen. Identical artifacts are relative symlinks.
+
+`cold` captures the first reopen. `native` adds a distinct body paragraph to each
+duplicate-title page through `tools/native/created-pages.ps1`; `native/cold`
+reopens that result in another cache. `followup` prefixes those bodies with
+`Rust + ` and the titles with `Reviewed `; `followup/cold` reopens that result.
+The retained `provenance.json` records the native capture inputs and clone removal.
+
+After building workspace examples, run `python -m unittest discover -s tools
+-p test_page_creation.py -v`. It compares native XML content and character formats,
+ordered identities, and complete active object graphs across each cold reopen.
+These thirteen-page comparisons need no graph normalization.
+
+Export fresh Rust candidates by setting each variable to a new output directory:
+
+| Core integration test (`cargo test -p onestore --test page TEST -- --exact`) | Variable |
+| --- | --- |
+| `native_section_accepts_empty_and_titled_pages_with_preserved_history` | `ONESTORE_PAGE_CREATION_OUTPUT` |
+| `native_changes_on_created_pages_accept_rust_followups` | `ONESTORE_PAGE_FOLLOWUP_OUTPUT` |
+| `repeated_page_creation_crosses_root_fragments_counters_and_section_checkpoints` | `ONESTORE_PAGE_STRESS_OUTPUT` |
+
+The last test produces 521 pages, crosses a section revision checkpoint and
+transaction-counter rollover, and checks that every unpublished candidate still
+exposes the complete preceding page order. Its large native capture is local
+evidence. Reproduce with `tools/native_runner.py INPUT OUTPUT --expected-pages 521
+--collect-notebook`, then `PYTHONPATH=tools python tools/verify_page_creation.py
+INPUT OUTPUT`. That comparison permits only exact native materialization of a
+previously absent section metadata copy; all other graph fields compare exactly.
+
+This core operation deliberately creates one optional title outline without
+generated date/time text or template content. MS-ONE 2.2.50 permits a title with
+one or two outlines. The fixture establishes this explicit empty-page contract.
diff --git a/corpus/page-lifecycle/creation/candidate/Lifecycle.one b/corpus/page-lifecycle/creation/candidate/Lifecycle.one
new file mode 100644
index 0000000000000000000000000000000000000000..3dac230dfcad0e261e9327e945c79ed84af025b7
Binary files /dev/null and b/corpus/page-lifecycle/creation/candidate/Lifecycle.one differ
diff --git a/corpus/page-lifecycle/creation/cold/notebook/Lifecycle.one b/corpus/page-lifecycle/creation/cold/notebook/Lifecycle.one
new file mode 100644
index 0000000000000000000000000000000000000000..776a303621a5dc0fe041cfb0f6bcfb29784f5212
Binary files /dev/null and b/corpus/page-lifecycle/creation/cold/notebook/Lifecycle.one differ
diff --git a/corpus/page-lifecycle/creation/cold/read/hierarchy.xml b/corpus/page-lifecycle/creation/cold/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..5f5f4541a71288ce6f039105b1bef2b6cc92bbce
--- /dev/null
+++ b/corpus/page-lifecycle/creation/cold/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-lifecycle/creation/cold/read/page-000.xml b/corpus/page-lifecycle/creation/cold/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..b74db02740bf5f6664bec471f6ceb4edfa69e947
--- /dev/null
+++ b/corpus/page-lifecycle/creation/cold/read/page-000.xml
@@ -0,0 +1,4 @@
+
+Body child 🦀 é preserved]]>
diff --git a/corpus/page-lifecycle/creation/cold/read/page-001.xml b/corpus/page-lifecycle/creation/cold/read/page-001.xml
new file mode 100644
index 0000000000000000000000000000000000000000..62991791c86033a1286a58fba2ca36558e3150e6
--- /dev/null
+++ b/corpus/page-lifecycle/creation/cold/read/page-001.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-lifecycle/creation/cold/read/page-002.xml b/corpus/page-lifecycle/creation/cold/read/page-002.xml
new file mode 100644
index 0000000000000000000000000000000000000000..a4888c4c3a60c26e6a1e7527a818fa76c0000738
--- /dev/null
+++ b/corpus/page-lifecycle/creation/cold/read/page-002.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-lifecycle/creation/cold/read/page-003.xml b/corpus/page-lifecycle/creation/cold/read/page-003.xml
new file mode 100644
index 0000000000000000000000000000000000000000..0a4fa38b36604ea31858926fc28563e3d48fce5e
--- /dev/null
+++ b/corpus/page-lifecycle/creation/cold/read/page-003.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-lifecycle/creation/cold/read/page-004.xml b/corpus/page-lifecycle/creation/cold/read/page-004.xml
new file mode 100644
index 0000000000000000000000000000000000000000..36b3f48ded7416caa1a1a3493c5ce738d378dd59
--- /dev/null
+++ b/corpus/page-lifecycle/creation/cold/read/page-004.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-lifecycle/creation/cold/read/page-005.xml b/corpus/page-lifecycle/creation/cold/read/page-005.xml
new file mode 100644
index 0000000000000000000000000000000000000000..2841ac8de01c2941a57d6a5781785ce87c58f233
--- /dev/null
+++ b/corpus/page-lifecycle/creation/cold/read/page-005.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-lifecycle/creation/cold/read/page-006.xml b/corpus/page-lifecycle/creation/cold/read/page-006.xml
new file mode 100644
index 0000000000000000000000000000000000000000..9e25e11773ae0c3c673cf196ba3c8d353f9cf201
--- /dev/null
+++ b/corpus/page-lifecycle/creation/cold/read/page-006.xml
@@ -0,0 +1,4 @@
+
+Body grandchild 🦀 é preserved]]>
diff --git a/corpus/page-lifecycle/creation/cold/read/page-007.xml b/corpus/page-lifecycle/creation/cold/read/page-007.xml
new file mode 100644
index 0000000000000000000000000000000000000000..d4d158edf3e7f04a47d7e6fe30985c6e43b4eba8
--- /dev/null
+++ b/corpus/page-lifecycle/creation/cold/read/page-007.xml
@@ -0,0 +1,4 @@
+
+Body parent 🦀 é preserved]]>
diff --git a/corpus/page-lifecycle/creation/cold/read/page-008.xml b/corpus/page-lifecycle/creation/cold/read/page-008.xml
new file mode 100644
index 0000000000000000000000000000000000000000..8f25f7fb29c1de845d267ae0a7432186062da69a
--- /dev/null
+++ b/corpus/page-lifecycle/creation/cold/read/page-008.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-lifecycle/creation/cold/read/page-009.xml b/corpus/page-lifecycle/creation/cold/read/page-009.xml
new file mode 100644
index 0000000000000000000000000000000000000000..9bb9dde27336654424657d5c9e644849d564507d
--- /dev/null
+++ b/corpus/page-lifecycle/creation/cold/read/page-009.xml
@@ -0,0 +1,4 @@
+
+Body duplicate 🦀 é preserved]]>
diff --git a/corpus/page-lifecycle/creation/cold/read/page-010.xml b/corpus/page-lifecycle/creation/cold/read/page-010.xml
new file mode 100644
index 0000000000000000000000000000000000000000..265b775930e7d1769f636d1662b677a553696018
--- /dev/null
+++ b/corpus/page-lifecycle/creation/cold/read/page-010.xml
@@ -0,0 +1,4 @@
+
+Body automatic 🦀 é preserved]]>
diff --git a/corpus/page-lifecycle/creation/cold/read/page-011.xml b/corpus/page-lifecycle/creation/cold/read/page-011.xml
new file mode 100644
index 0000000000000000000000000000000000000000..7c03f6ae8fca64df7719f2b108cb98ceb5ba8e83
--- /dev/null
+++ b/corpus/page-lifecycle/creation/cold/read/page-011.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-lifecycle/creation/cold/read/page-012.xml b/corpus/page-lifecycle/creation/cold/read/page-012.xml
new file mode 100644
index 0000000000000000000000000000000000000000..9890c342b59f523cac95cb9193722bad8b6ca62e
--- /dev/null
+++ b/corpus/page-lifecycle/creation/cold/read/page-012.xml
@@ -0,0 +1,4 @@
+
+Body trailing 🦀 é preserved]]>
diff --git a/corpus/page-lifecycle/creation/followup/Lifecycle.one b/corpus/page-lifecycle/creation/followup/Lifecycle.one
new file mode 100644
index 0000000000000000000000000000000000000000..b6f78fe9a2da590e540d55a842742eb36fe7d173
Binary files /dev/null and b/corpus/page-lifecycle/creation/followup/Lifecycle.one differ
diff --git a/corpus/page-lifecycle/creation/followup/cold/notebook/Lifecycle.one b/corpus/page-lifecycle/creation/followup/cold/notebook/Lifecycle.one
new file mode 100644
index 0000000000000000000000000000000000000000..206670d8221019c61e7133b8626a14c837a5d3f1
Binary files /dev/null and b/corpus/page-lifecycle/creation/followup/cold/notebook/Lifecycle.one differ
diff --git a/corpus/page-lifecycle/creation/followup/cold/read/hierarchy.xml b/corpus/page-lifecycle/creation/followup/cold/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..f7d9ebde7686be747df76426cc89501e37dcee14
--- /dev/null
+++ b/corpus/page-lifecycle/creation/followup/cold/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-lifecycle/creation/followup/cold/read/page-000.xml b/corpus/page-lifecycle/creation/followup/cold/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..4742bf7a49b14bfeaec07003c7b4f461063d324a
--- /dev/null
+++ b/corpus/page-lifecycle/creation/followup/cold/read/page-000.xml
@@ -0,0 +1,4 @@
+
+Body child 🦀 é preserved]]>
diff --git a/corpus/page-lifecycle/creation/followup/cold/read/page-001.xml b/corpus/page-lifecycle/creation/followup/cold/read/page-001.xml
new file mode 100644
index 0000000000000000000000000000000000000000..a2161a614bca5619a0c11e013b97c1610daeb6c0
--- /dev/null
+++ b/corpus/page-lifecycle/creation/followup/cold/read/page-001.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-lifecycle/creation/followup/cold/read/page-002.xml b/corpus/page-lifecycle/creation/followup/cold/read/page-002.xml
new file mode 100644
index 0000000000000000000000000000000000000000..8ae4e38d376aafaf3afe54f4fc83bcb156f694ef
--- /dev/null
+++ b/corpus/page-lifecycle/creation/followup/cold/read/page-002.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-lifecycle/creation/followup/cold/read/page-003.xml b/corpus/page-lifecycle/creation/followup/cold/read/page-003.xml
new file mode 100644
index 0000000000000000000000000000000000000000..8068f394f7b8bfa45dc97905c4c1f3e69ce07354
--- /dev/null
+++ b/corpus/page-lifecycle/creation/followup/cold/read/page-003.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-lifecycle/creation/followup/cold/read/page-004.xml b/corpus/page-lifecycle/creation/followup/cold/read/page-004.xml
new file mode 100644
index 0000000000000000000000000000000000000000..9fa30270c14770a10545f36ef6b13e509c2bb1e0
--- /dev/null
+++ b/corpus/page-lifecycle/creation/followup/cold/read/page-004.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-lifecycle/creation/followup/cold/read/page-005.xml b/corpus/page-lifecycle/creation/followup/cold/read/page-005.xml
new file mode 100644
index 0000000000000000000000000000000000000000..0edd9bcab1194c3d9d1e8e688c7975b2beebd093
--- /dev/null
+++ b/corpus/page-lifecycle/creation/followup/cold/read/page-005.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-lifecycle/creation/followup/cold/read/page-006.xml b/corpus/page-lifecycle/creation/followup/cold/read/page-006.xml
new file mode 100644
index 0000000000000000000000000000000000000000..4b64cee072f1fd557c142d26e335f3d8307cd2f1
--- /dev/null
+++ b/corpus/page-lifecycle/creation/followup/cold/read/page-006.xml
@@ -0,0 +1,4 @@
+
+Body parent 🦀 é preserved]]>
diff --git a/corpus/page-lifecycle/creation/followup/cold/read/page-007.xml b/corpus/page-lifecycle/creation/followup/cold/read/page-007.xml
new file mode 100644
index 0000000000000000000000000000000000000000..d4fad8b51dc23795d32144dea512a0308d6bb8f1
--- /dev/null
+++ b/corpus/page-lifecycle/creation/followup/cold/read/page-007.xml
@@ -0,0 +1,4 @@
+
+Body grandchild 🦀 é preserved]]>
diff --git a/corpus/page-lifecycle/creation/followup/cold/read/page-008.xml b/corpus/page-lifecycle/creation/followup/cold/read/page-008.xml
new file mode 100644
index 0000000000000000000000000000000000000000..d26253e67ff7df8de34f3f04b9c19fd71b09ee97
--- /dev/null
+++ b/corpus/page-lifecycle/creation/followup/cold/read/page-008.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-lifecycle/creation/followup/cold/read/page-009.xml b/corpus/page-lifecycle/creation/followup/cold/read/page-009.xml
new file mode 100644
index 0000000000000000000000000000000000000000..5457a4e122714c0a67224ed80bc9ec1a7aba3b4f
--- /dev/null
+++ b/corpus/page-lifecycle/creation/followup/cold/read/page-009.xml
@@ -0,0 +1,4 @@
+
+Body duplicate 🦀 é preserved]]>
diff --git a/corpus/page-lifecycle/creation/followup/cold/read/page-010.xml b/corpus/page-lifecycle/creation/followup/cold/read/page-010.xml
new file mode 100644
index 0000000000000000000000000000000000000000..e756008ceb91813adc6f64b434c7f1a84ac5bf4a
--- /dev/null
+++ b/corpus/page-lifecycle/creation/followup/cold/read/page-010.xml
@@ -0,0 +1,4 @@
+
+Body automatic 🦀 é preserved]]>
diff --git a/corpus/page-lifecycle/creation/followup/cold/read/page-011.xml b/corpus/page-lifecycle/creation/followup/cold/read/page-011.xml
new file mode 100644
index 0000000000000000000000000000000000000000..42ea81a0966b70ee621212a3552ae3ae90be0989
--- /dev/null
+++ b/corpus/page-lifecycle/creation/followup/cold/read/page-011.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-lifecycle/creation/followup/cold/read/page-012.xml b/corpus/page-lifecycle/creation/followup/cold/read/page-012.xml
new file mode 100644
index 0000000000000000000000000000000000000000..9abc381b9155abd5962d1a4f8c41d4d78862067d
--- /dev/null
+++ b/corpus/page-lifecycle/creation/followup/cold/read/page-012.xml
@@ -0,0 +1,4 @@
+
+Body trailing 🦀 é preserved]]>
diff --git a/corpus/page-lifecycle/creation/native/cold/notebook/Lifecycle.one b/corpus/page-lifecycle/creation/native/cold/notebook/Lifecycle.one
new file mode 120000
index 0000000000000000000000000000000000000000..0c82f1045162a6bde2dc5f5fe0881c0130aca14d
--- /dev/null
+++ b/corpus/page-lifecycle/creation/native/cold/notebook/Lifecycle.one
@@ -0,0 +1 @@
+../../notebook/Lifecycle.one
\ No newline at end of file
diff --git a/corpus/page-lifecycle/creation/native/cold/read/hierarchy.xml b/corpus/page-lifecycle/creation/native/cold/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..4836e778e6af3fff3797afe9cad4ffcee75bb427
--- /dev/null
+++ b/corpus/page-lifecycle/creation/native/cold/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-lifecycle/creation/native/cold/read/page-000.xml b/corpus/page-lifecycle/creation/native/cold/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..30e03f8c5d7c32095c10ab54d530f2834febf2c7
--- /dev/null
+++ b/corpus/page-lifecycle/creation/native/cold/read/page-000.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-lifecycle/creation/native/cold/read/page-001.xml b/corpus/page-lifecycle/creation/native/cold/read/page-001.xml
new file mode 100644
index 0000000000000000000000000000000000000000..686a57f9a0006bf55f61dffc6ef0b48bd1ff2c31
--- /dev/null
+++ b/corpus/page-lifecycle/creation/native/cold/read/page-001.xml
@@ -0,0 +1,4 @@
+
+Body grandchild 🦀 é preserved]]>
diff --git a/corpus/page-lifecycle/creation/native/cold/read/page-002.xml b/corpus/page-lifecycle/creation/native/cold/read/page-002.xml
new file mode 100644
index 0000000000000000000000000000000000000000..bf6f1f3285ced61f86352de7d9720ce3902b66a5
--- /dev/null
+++ b/corpus/page-lifecycle/creation/native/cold/read/page-002.xml
@@ -0,0 +1,4 @@
+
+Body parent 🦀 é preserved]]>
diff --git a/corpus/page-lifecycle/creation/native/cold/read/page-003.xml b/corpus/page-lifecycle/creation/native/cold/read/page-003.xml
new file mode 100644
index 0000000000000000000000000000000000000000..b4a0399dc12d1b401c358ef95898a6f7e7e9dd02
--- /dev/null
+++ b/corpus/page-lifecycle/creation/native/cold/read/page-003.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-lifecycle/creation/native/cold/read/page-004.xml b/corpus/page-lifecycle/creation/native/cold/read/page-004.xml
new file mode 100644
index 0000000000000000000000000000000000000000..bdde6f6c2dafbe40a1af2fdc132ae2ad526a51b0
--- /dev/null
+++ b/corpus/page-lifecycle/creation/native/cold/read/page-004.xml
@@ -0,0 +1,4 @@
+
+Body child 🦀 é preserved]]>
diff --git a/corpus/page-lifecycle/creation/native/cold/read/page-005.xml b/corpus/page-lifecycle/creation/native/cold/read/page-005.xml
new file mode 100644
index 0000000000000000000000000000000000000000..556edf47723978c3db8c7ed78d6d841ad73fdde9
--- /dev/null
+++ b/corpus/page-lifecycle/creation/native/cold/read/page-005.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-lifecycle/creation/native/cold/read/page-006.xml b/corpus/page-lifecycle/creation/native/cold/read/page-006.xml
new file mode 100644
index 0000000000000000000000000000000000000000..a7314ac9046523033e145cb67980fbb75e4b4cd1
--- /dev/null
+++ b/corpus/page-lifecycle/creation/native/cold/read/page-006.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-lifecycle/creation/native/cold/read/page-007.xml b/corpus/page-lifecycle/creation/native/cold/read/page-007.xml
new file mode 100644
index 0000000000000000000000000000000000000000..1af4f16e7bd0db14f4ba7f8d1679d336822795b3
--- /dev/null
+++ b/corpus/page-lifecycle/creation/native/cold/read/page-007.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-lifecycle/creation/native/cold/read/page-008.xml b/corpus/page-lifecycle/creation/native/cold/read/page-008.xml
new file mode 100644
index 0000000000000000000000000000000000000000..7464cc7dc7fea87e99960b6ad14fb2cceff20be4
--- /dev/null
+++ b/corpus/page-lifecycle/creation/native/cold/read/page-008.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-lifecycle/creation/native/cold/read/page-009.xml b/corpus/page-lifecycle/creation/native/cold/read/page-009.xml
new file mode 100644
index 0000000000000000000000000000000000000000..c8d4a0a8ed5b1ad9a362afce806bc09ed4008621
--- /dev/null
+++ b/corpus/page-lifecycle/creation/native/cold/read/page-009.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-lifecycle/creation/native/cold/read/page-010.xml b/corpus/page-lifecycle/creation/native/cold/read/page-010.xml
new file mode 100644
index 0000000000000000000000000000000000000000..2ac049eeed39fea44ae2da23e392b7591f1b06b7
--- /dev/null
+++ b/corpus/page-lifecycle/creation/native/cold/read/page-010.xml
@@ -0,0 +1,4 @@
+
+Body trailing 🦀 é preserved]]>
diff --git a/corpus/page-lifecycle/creation/native/cold/read/page-011.xml b/corpus/page-lifecycle/creation/native/cold/read/page-011.xml
new file mode 100644
index 0000000000000000000000000000000000000000..43386377e0063d58ee24dd56f0f58a8a856daccd
--- /dev/null
+++ b/corpus/page-lifecycle/creation/native/cold/read/page-011.xml
@@ -0,0 +1,4 @@
+
+Body duplicate 🦀 é preserved]]>
diff --git a/corpus/page-lifecycle/creation/native/cold/read/page-012.xml b/corpus/page-lifecycle/creation/native/cold/read/page-012.xml
new file mode 100644
index 0000000000000000000000000000000000000000..d8b4366fe460f248ae133fd9ec8fd8e3e7c70568
--- /dev/null
+++ b/corpus/page-lifecycle/creation/native/cold/read/page-012.xml
@@ -0,0 +1,4 @@
+
+Body automatic 🦀 é preserved]]>
diff --git a/corpus/page-lifecycle/creation/native/notebook/Lifecycle.one b/corpus/page-lifecycle/creation/native/notebook/Lifecycle.one
new file mode 100644
index 0000000000000000000000000000000000000000..b60dfff4780a6d8dd3826fc2a515033d2e5f0cae
Binary files /dev/null and b/corpus/page-lifecycle/creation/native/notebook/Lifecycle.one differ
diff --git a/corpus/page-lifecycle/creation/native/read/hierarchy.xml b/corpus/page-lifecycle/creation/native/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..46f7de2e422eaa62f29644d33dd752fb0527c358
--- /dev/null
+++ b/corpus/page-lifecycle/creation/native/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-lifecycle/creation/native/read/page-000.xml b/corpus/page-lifecycle/creation/native/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..b8cab291fd9a7f09e47daa1b311825a5035b039e
--- /dev/null
+++ b/corpus/page-lifecycle/creation/native/read/page-000.xml
@@ -0,0 +1,4 @@
+
+Body trailing 🦀 é preserved]]>
diff --git a/corpus/page-lifecycle/creation/native/read/page-001.xml b/corpus/page-lifecycle/creation/native/read/page-001.xml
new file mode 100644
index 0000000000000000000000000000000000000000..3015c50816186e839b87dc7dc7d59d73f27fc535
--- /dev/null
+++ b/corpus/page-lifecycle/creation/native/read/page-001.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-lifecycle/creation/native/read/page-002.xml b/corpus/page-lifecycle/creation/native/read/page-002.xml
new file mode 100644
index 0000000000000000000000000000000000000000..d7f2c174e35a8ae7f7eb175293702d85748e8bf5
--- /dev/null
+++ b/corpus/page-lifecycle/creation/native/read/page-002.xml
@@ -0,0 +1,4 @@
+
+Body duplicate 🦀 é preserved]]>
diff --git a/corpus/page-lifecycle/creation/native/read/page-003.xml b/corpus/page-lifecycle/creation/native/read/page-003.xml
new file mode 100644
index 0000000000000000000000000000000000000000..e9d43bf99f32856357e6bbd970d6bcc248cd6111
--- /dev/null
+++ b/corpus/page-lifecycle/creation/native/read/page-003.xml
@@ -0,0 +1,4 @@
+
+Body automatic 🦀 é preserved]]>
diff --git a/corpus/page-lifecycle/creation/native/read/page-004.xml b/corpus/page-lifecycle/creation/native/read/page-004.xml
new file mode 100644
index 0000000000000000000000000000000000000000..8ed9596a931a9c3ce235802b6c4b981ca388cb89
--- /dev/null
+++ b/corpus/page-lifecycle/creation/native/read/page-004.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-lifecycle/creation/native/read/page-005.xml b/corpus/page-lifecycle/creation/native/read/page-005.xml
new file mode 100644
index 0000000000000000000000000000000000000000..3b4a678ee36c1430ad8c7de33bb226d2b7ec9b0f
--- /dev/null
+++ b/corpus/page-lifecycle/creation/native/read/page-005.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-lifecycle/creation/native/read/page-006.xml b/corpus/page-lifecycle/creation/native/read/page-006.xml
new file mode 100644
index 0000000000000000000000000000000000000000..4693795ee03c300bd507cb4737d8c9550f6d24bf
--- /dev/null
+++ b/corpus/page-lifecycle/creation/native/read/page-006.xml
@@ -0,0 +1,4 @@
+
+Body grandchild 🦀 é preserved]]>
diff --git a/corpus/page-lifecycle/creation/native/read/page-007.xml b/corpus/page-lifecycle/creation/native/read/page-007.xml
new file mode 100644
index 0000000000000000000000000000000000000000..e818ae26b54fd7bd9b60249de373bceaaa087704
--- /dev/null
+++ b/corpus/page-lifecycle/creation/native/read/page-007.xml
@@ -0,0 +1,4 @@
+
+Body parent 🦀 é preserved]]>
diff --git a/corpus/page-lifecycle/creation/native/read/page-008.xml b/corpus/page-lifecycle/creation/native/read/page-008.xml
new file mode 100644
index 0000000000000000000000000000000000000000..b4ce7a9d9152ced7d6a6790478ac895cf31e2af5
--- /dev/null
+++ b/corpus/page-lifecycle/creation/native/read/page-008.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-lifecycle/creation/native/read/page-009.xml b/corpus/page-lifecycle/creation/native/read/page-009.xml
new file mode 100644
index 0000000000000000000000000000000000000000..5ef8f5a66daa4a3e9e8d60818bfff6dccb6a319f
--- /dev/null
+++ b/corpus/page-lifecycle/creation/native/read/page-009.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-lifecycle/creation/native/read/page-010.xml b/corpus/page-lifecycle/creation/native/read/page-010.xml
new file mode 100644
index 0000000000000000000000000000000000000000..34434e16da993550ce35f3520fcfa45a2178e281
--- /dev/null
+++ b/corpus/page-lifecycle/creation/native/read/page-010.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-lifecycle/creation/native/read/page-011.xml b/corpus/page-lifecycle/creation/native/read/page-011.xml
new file mode 100644
index 0000000000000000000000000000000000000000..b97f3354d80313f0e6a5c44e57a4b0a46c29f6c8
--- /dev/null
+++ b/corpus/page-lifecycle/creation/native/read/page-011.xml
@@ -0,0 +1,4 @@
+
+Body child 🦀 é preserved]]>
diff --git a/corpus/page-lifecycle/creation/native/read/page-012.xml b/corpus/page-lifecycle/creation/native/read/page-012.xml
new file mode 100644
index 0000000000000000000000000000000000000000..5873da638217cf192a44a47a46ceb3eb70532f23
--- /dev/null
+++ b/corpus/page-lifecycle/creation/native/read/page-012.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/page-lifecycle/creation/provenance.json b/corpus/page-lifecycle/creation/provenance.json
new file mode 100644
index 0000000000000000000000000000000000000000..09523b2d417a016d27dd110a7251ccc4491d729b
--- /dev/null
+++ b/corpus/page-lifecycle/creation/provenance.json
@@ -0,0 +1,115 @@
+{
+ "cold": {
+ "capture": "evidence/m10/page-create-cold",
+ "run": {
+ "notebook": "/Users/clo/dev/one/evidence/m10/page-create-candidate",
+ "expected_pages": 13,
+ "author": null,
+ "author_timeout_seconds": 600,
+ "inspect": false,
+ "collect_notebook": true,
+ "base": {
+ "file": "win7-office-base.qcow2",
+ "format": "qcow2",
+ "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
+ "virtual_size": 68719476736
+ },
+ "scripts": {
+ "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
+ "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41"
+ }
+ },
+ "machine": {
+ "name": "m6-4b25125e",
+ "hostname": "ONE-M6-4B25125E"
+ },
+ "teardown": {
+ "absent": true
+ }
+ },
+ "native": {
+ "capture": "evidence/m10/page-create-native-edit",
+ "run": {
+ "notebook": "/Users/clo/dev/one/evidence/m10/page-create-cold/notebook",
+ "expected_pages": 13,
+ "author": "tools/native/created-pages.ps1",
+ "author_timeout_seconds": 600,
+ "inspect": false,
+ "collect_notebook": true,
+ "base": {
+ "file": "win7-office-base.qcow2",
+ "format": "qcow2",
+ "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
+ "virtual_size": 68719476736
+ },
+ "scripts": {
+ "author.ps1": "f7d94cfa418a971c2911bb3f57b9a9991c9e0b91d0f7d9c7526ca7c8cd5adc42",
+ "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
+ "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41"
+ }
+ },
+ "machine": {
+ "name": "m6-71dafa89",
+ "hostname": "ONE-M6-71DAFA89"
+ },
+ "teardown": {
+ "absent": true
+ }
+ },
+ "native/cold": {
+ "capture": "evidence/m10/page-create-native-edit-cold",
+ "run": {
+ "notebook": "/Users/clo/dev/one/evidence/m10/page-create-native-edit/notebook",
+ "expected_pages": 13,
+ "author": null,
+ "author_timeout_seconds": 600,
+ "inspect": false,
+ "collect_notebook": true,
+ "base": {
+ "file": "win7-office-base.qcow2",
+ "format": "qcow2",
+ "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
+ "virtual_size": 68719476736
+ },
+ "scripts": {
+ "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
+ "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41"
+ }
+ },
+ "machine": {
+ "name": "m6-eabc83c1",
+ "hostname": "ONE-M6-EABC83C1"
+ },
+ "teardown": {
+ "absent": true
+ }
+ },
+ "followup/cold": {
+ "capture": "evidence/m10/page-create-rust-followup-cold",
+ "run": {
+ "notebook": "/Users/clo/dev/one/evidence/m10/page-create-rust-followup",
+ "expected_pages": 13,
+ "author": null,
+ "author_timeout_seconds": 600,
+ "inspect": false,
+ "collect_notebook": true,
+ "base": {
+ "file": "win7-office-base.qcow2",
+ "format": "qcow2",
+ "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
+ "virtual_size": 68719476736
+ },
+ "scripts": {
+ "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
+ "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41"
+ }
+ },
+ "machine": {
+ "name": "m6-5a91d9d1",
+ "hostname": "ONE-M6-5A91D9D1"
+ },
+ "teardown": {
+ "absent": true
+ }
+ }
+}
diff --git a/crates/onestore/README.md b/crates/onestore/README.md
index d4ae6e6a8e6a3a2eb81c0c2ec42fac14f1a6ec21..ef5de8c3d810f34f1293aae8bddf90ae6fef35f9 100644
--- a/crates/onestore/README.md
+++ b/crates/onestore/README.md
@@ -45,6 +45,7 @@ harness also accepts `--client-profile release`.
| `document::Document`, `Revision::text_runs` | Interpret document objects and inherited text formatting while retaining unknown properties and revision identities |
| `protected::UnlockedSection` (optional feature) | Own decoded buffers for explicit known-password inspection; clear those buffers on drop; derived document strings/exports remain caller-owned |
| `create_section` | Create one page containing one plain-text paragraph and an author, including Unicode |
+| `PageCreation`, `PreparedEdit::create_page` | Add an empty top-level page and its section entry atomically, retaining page identities across retries |
| `create_table_of_contents` | Create ordered section entries from filenames and file identities |
| `replace_property_bytes` | Append one scalar-property revision; preserve prior revisions and unrelated property values and references |
| `replace_text`, `commit_text`, `commit_file_text` | Replace a UTF-16 range across ordinary text runs; publish text, run boundaries and modification time together |
@@ -73,6 +74,13 @@ sections retain their encrypted structure and payloads. With the optional
AES-128/CBC, SHA-1 password wrappers into a borrowed document view. Incorrect
passwords, unsupported protection profiles and work-limit failures remain distinct.
The source stays encrypted; protected writes are rejected.
+`PageCreation::new` appends, or inserts before the first page space of an existing
+series. `Some("")` creates an empty title field; `None` omits the title node.
+The page has no body outlines, generated date/time text or applied template.
+Retain the intent to preserve its page, title and space identities; existing
+identities require reconciliation before retry. Body insertion and title edits
+use those identities through the existing APIs. [Native page-creation fixtures](../../corpus/page-lifecycle/creation/README.md)
+cover duplicate Unicode titles, native edits and Rust follow-up edits.
Insertions update child references, reference counts, modification times and automatic
titles atomically. Paragraphs can be nested or inserted into table cells; outline
coordinates use points. `Insertion::with_formatting` includes nonoverlapping UTF-16
diff --git a/crates/onestore/src/commit.rs b/crates/onestore/src/commit.rs
index 4e3d2507a46971d073f8375baddd705eae20a8ed..49aa9d68405c10697d372f37cebbd3b15110aa36 100644
--- a/crates/onestore/src/commit.rs
+++ b/crates/onestore/src/commit.rs
@@ -183,6 +183,14 @@ pub struct PreparedEdit<'a> {
}
impl<'a> PreparedEdit<'a> {
+ /// Creates a page and its section entry in one transaction, retaining the intent's identities.
+ pub fn create_page(source: &'a [u8], page: &crate::PageCreation) -> Result {
+ Ok(Self {
+ source,
+ written: page.apply(source)?,
+ })
+ }
+
/// Moves or removes a subtree and normalizes its containers in one revision.
pub fn tree(
source: &'a [u8],
diff --git a/crates/onestore/src/lib.rs b/crates/onestore/src/lib.rs
index c5e2f825aa45f9d0bdddb9e643ff4ab2451b2691..5334f874fc04fd30ede8640713bf77bdfe8749c3 100644
--- a/crates/onestore/src/lib.rs
+++ b/crates/onestore/src/lib.rs
@@ -12,6 +12,7 @@ mod formatting;
mod insertion;
mod objects;
mod outline;
+mod page;
mod paragraph;
mod properties;
#[cfg(feature = "protected")]
@@ -35,6 +36,7 @@ pub use formatting::TextAttribute;
pub use insertion::Insertion;
pub use objects::{Object, ObjectData, ObjectReferences, ResolvedRevision};
pub use outline::OutlineEdit;
+pub use page::PageCreation;
pub use paragraph::{ParagraphJoin, ParagraphSplit};
pub use properties::{IdStream, Property, PropertySets, Value};
pub use revisions::{ExGuid, ObjectSpace, Revision, RevisionIndex};
diff --git a/crates/onestore/src/page.rs b/crates/onestore/src/page.rs
new file mode 100644
index 0000000000000000000000000000000000000000..bf44af9cf36e99aa638570ab4770adf2108157c2
--- /dev/null
+++ b/crates/onestore/src/page.rs
@@ -0,0 +1,260 @@
+use crate::{
+ Error, ExGuid,
+ create::{current_timestamps, properties, string},
+ document::{Document, Kind},
+ write::{PropertyObject, RevisionEdit, fresh_guid, write_revisions},
+};
+use serde::{Deserialize, Serialize};
+use std::{collections::BTreeMap, sync::Arc};
+
+fn invalid(message: &'static str) -> Error {
+ Error { offset: 0, message }
+}
+
+/// An empty top-level page with stable identities and creation time.
+/// Retain the intent across retries; an existing page identity rejects duplicate creation.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct PageCreation {
+ guid: [u8; 16],
+ series_guid: [u8; 16],
+ before: Option,
+ title: Option,
+ author: String,
+ created: u32,
+}
+
+impl PageCreation {
+ /// Appends a page, or inserts before the first page space of an existing series.
+ /// `Some("")` creates an empty title field; `None` creates a page without a title node.
+ /// The page has no body outlines or generated date/time text and does not apply a template.
+ pub fn new(before: Option, title: Option<&str>, author: &str) -> Result {
+ let page = Self {
+ guid: fresh_guid()?,
+ series_guid: fresh_guid()?,
+ before,
+ title: title.map(str::to_owned),
+ author: author.to_owned(),
+ created: current_timestamps()?.0,
+ };
+ page.validate()?;
+ Ok(page)
+ }
+
+ pub fn space(&self) -> ExGuid {
+ ExGuid {
+ guid: self.guid,
+ n: 1,
+ }
+ }
+
+ pub fn object(&self) -> ExGuid {
+ ExGuid {
+ guid: self.guid,
+ n: 12,
+ }
+ }
+
+ pub fn title_object(&self) -> Option {
+ self.title.as_ref().map(|_| ExGuid {
+ guid: self.guid,
+ n: 16,
+ })
+ }
+
+ fn validate(&self) -> Result<(), Error> {
+ if self.guid == [0; 16]
+ || self.series_guid == [0; 16]
+ || self.guid == self.series_guid
+ || self.author.contains('\0')
+ || self.before.is_some_and(|id| id.guid == [0; 16])
+ || self
+ .title
+ .as_ref()
+ .is_some_and(|title| title.contains(['\0', '\r', '\n', '\u{fffc}', '\u{fddf}']))
+ {
+ return Err(invalid(
+ "Use a new page identity, an existing page anchor, and ordinary single-line title text",
+ ));
+ }
+ Ok(())
+ }
+
+ pub(crate) fn apply(&self, source: &[u8]) -> Result, Error> {
+ self.validate()?;
+ write_revisions(source, |index| {
+ if index.spaces.contains_key(&self.space()) {
+ return Err(invalid(
+ "This page identity already exists; reconcile the original creation",
+ ));
+ }
+ let document = Document::parse(index)?;
+ document.pages()?;
+ let section = &document.spaces[&document.root];
+ let view = §ion.revisions[§ion.contexts[&ExGuid::default()]];
+ let section_id = view.roots[&1];
+ let section_node = &view.nodes[§ion_id];
+ if !matches!(section_node.kind, Kind::Section { .. })
+ || section_node.extra[0]
+ .iter()
+ .any(|field| field.id == 0x88001cde)
+ {
+ return Err(invalid("Choose an editable section for the new page"));
+ }
+ let position = match self.before {
+ None => section_node.children.len(),
+ Some(before) => section_node
+ .children
+ .iter()
+ .position(|id| view.nodes[id].spaces.first() == Some(&before))
+ .ok_or_else(|| {
+ invalid("Insert before the first page of an existing series, or append")
+ })?,
+ };
+ let raw = index.resolve(document.root, section.contexts[&ExGuid::default()])?;
+ let id = |n| ExGuid { guid: self.guid, n };
+ let reference = |n: u32| n.to_le_bytes().to_vec();
+ let timestamp = ((u64::from(self.created) + 315532800 + 11644473600) * 10000000)
+ .to_le_bytes()
+ .to_vec();
+ let modified = || (0x14001d7a, self.created.to_le_bytes().to_vec());
+ let mut table = BTreeMap::from([(0, self.guid)]);
+ let mut metadata_id = id(1);
+ for (byte, salt) in metadata_id.guid.iter_mut().zip([
+ 0x31, 0xc0, 0xa8, 0x22, 0, 0x36, 0xee, 0x42, 0xb7, 0x14, 0xd7, 0xac, 0xda, 0x24,
+ 0x35, 0xe8,
+ ]) {
+ *byte ^= salt;
+ }
+ table.insert(1, metadata_id.guid);
+ let table = Arc::new(table);
+ let object = |jcid, values: Vec<_>| -> Result<_, Error> {
+ Ok(PropertyObject {
+ jcid,
+ bytes: properties(&values)?,
+ global_ids: Arc::clone(&table),
+ })
+ };
+ let title = self.title.as_deref().unwrap_or_default().trim_start();
+ let metadata = vec![
+ (0x1c001c30, self.guid.to_vec()),
+ (0x1c001cf3, string(title)),
+ (0x14001d82, reference(40)),
+ (0x1400348b, reference(40)),
+ (0x14001dff, reference(1)),
+ (0x18001c65, timestamp.clone()),
+ ];
+ let mut page = vec![
+ modified(),
+ (0x1c001d75, string(&self.author)),
+ (0x1c001d3c, string("")),
+ ];
+ if self.title.is_some() {
+ page.push((0x24001d5f, reference(13)));
+ }
+ let mut objects = BTreeMap::from([
+ (id(10), object(0x60037, vec![(0x24001c1f, reference(12))])?),
+ (id(11), object(0x20030, metadata.clone())?),
+ (id(12), object(0x6000b, page)?),
+ ]);
+ if let Some(title) = &self.title {
+ for (n, jcid, values) in [
+ (
+ 13,
+ 0x6002c,
+ vec![
+ modified(),
+ (0x24001c20, reference(14)),
+ (0x14001c14, 0_f32.to_le_bytes().to_vec()),
+ (0x14001c15, 0_f32.to_le_bytes().to_vec()),
+ ],
+ ),
+ (
+ 14,
+ 0x6000c,
+ vec![
+ modified(),
+ (0x24001c20, reference(15)),
+ (0x0c001c03, vec![1]),
+ ],
+ ),
+ (
+ 15,
+ 0x6000d,
+ vec![
+ modified(),
+ (0x24001c1f, reference(16)),
+ (0x0c001c03, vec![1]),
+ (0x20001d78, reference(17)),
+ (0x20001d79, reference(17)),
+ (0x14001d09, self.created.to_le_bytes().to_vec()),
+ (0x88001cb4, vec![]),
+ ],
+ ),
+ (
+ 16,
+ 0x6000e,
+ vec![
+ modified(),
+ (0x1c001c22, string(title)),
+ (0x24001e13, reference(19)),
+ (0x2000342c, reference(18)),
+ (0x10001cfe, 0x409_u16.to_le_bytes().to_vec()),
+ (0x88001cb4, vec![]),
+ ],
+ ),
+ (17, 0x120001, vec![(0x1c001d75, string(&self.author))]),
+ (
+ 18,
+ 0x12004d,
+ vec![
+ (0x1c00345a, string("PageTitle")),
+ (0x1c001c0a, string("Calibri")),
+ (0x10001c0b, 34_u16.to_le_bytes().to_vec()),
+ ],
+ ),
+ (19, 0x12004d, vec![(0x14001c3b, reference(0x409))]),
+ ] {
+ objects.insert(id(n), object(jcid, values)?);
+ }
+ }
+ let series = object(
+ 0x60008,
+ vec![
+ (0x1c001c30, self.series_guid.to_vec()),
+ (0x18001c65, timestamp),
+ (0x2c001d63, reference(1)),
+ (0x24003442, reference(257)),
+ ],
+ )?;
+ if raw.objects.contains_key(&id(2)) || raw.objects.contains_key(&metadata_id) {
+ return Err(invalid("The new page's section identities already exist"));
+ }
+ let mut parent = PropertyObject::from_object(&raw.objects[§ion_id])?;
+ let mut children = section_node.children.clone();
+ children.insert(position, id(2));
+ let mut references = Vec::new();
+ for child in children {
+ references.extend_from_slice(&parent.reference(child)?);
+ }
+ parent.set(&[(0x24001c20, &references)])?;
+ Ok(BTreeMap::from([
+ (
+ document.root,
+ RevisionEdit::Update(BTreeMap::from([
+ (section_id, parent),
+ (id(2), series),
+ (metadata_id, object(0x20030, metadata)?),
+ ])),
+ ),
+ (
+ self.space(),
+ RevisionEdit::Create {
+ roots: BTreeMap::from([(1, id(10)), (2, id(11))]),
+ objects,
+ },
+ ),
+ ]))
+ })
+ }
+}
diff --git a/crates/onestore/src/write.rs b/crates/onestore/src/write.rs
index aea01dddb259348307d1dd7e34eefe1762710257..6f3b7b67ce8f5681d1597d3ef3245939a9c6490d 100644
--- a/crates/onestore/src/write.rs
+++ b/crates/onestore/src/write.rs
@@ -354,6 +354,14 @@ pub(crate) struct PropertyObject {
pub global_ids: Arc>,
}
+pub(crate) enum RevisionEdit {
+ Update(BTreeMap),
+ Create {
+ roots: BTreeMap,
+ objects: BTreeMap,
+ },
+}
+
impl PropertyObject {
pub fn from_object(object: &crate::Object<'_>) -> Result {
let ObjectData::Properties(bytes) = object.data else {
@@ -597,13 +605,49 @@ pub(crate) fn write_revision(
message: "Object space has no active default revision",
})?;
let revision = index.resolve(space, rid)?;
- Ok(BTreeMap::from([(space, edit(&revision)?)]))
+ Ok(BTreeMap::from([(
+ space,
+ RevisionEdit::Update(edit(&revision)?),
+ )]))
})
}
+fn append_fragment(
+ source: &[u8],
+ output: &mut Vec,
+ list: &crate::NodeList,
+ nodes: &[Vec],
+) -> Result<(u32, usize)> {
+ let last_fragment = *list.fragments.last().unwrap();
+ let list_start = usize::try_from(last_fragment.offset).unwrap();
+ let list_id = u32::from_le_bytes(source[list_start + 8..list_start + 12].try_into().unwrap());
+ let chunk = append_list(output, list_id, nodes)?;
+ let start = usize::try_from(chunk.offset).unwrap();
+ let sequence = u32::try_from(list.fragments.len()).map_err(|_| Error {
+ offset: list_start,
+ message: "File-node fragment sequences are exhausted",
+ })?;
+ output[start + 12..start + 16].copy_from_slice(&sequence.to_le_bytes());
+ let last_node = list.nodes.last().unwrap();
+ let node_header = u32::from_le_bytes(
+ source[last_node.offset..last_node.offset + 4]
+ .try_into()
+ .unwrap(),
+ );
+ let nodes_end = last_node.offset + usize::try_from((node_header >> 10) & 0x1fff).unwrap();
+ let tail = list_start + usize::try_from(last_fragment.length).unwrap() - 20;
+ if tail - nodes_end >= 4 {
+ output[nodes_end..nodes_end + 4].copy_from_slice(&node(0xff, None, &[])?);
+ }
+ output[tail..tail + 8].copy_from_slice(&chunk.offset.to_le_bytes());
+ output[tail + 8..tail + 12].copy_from_slice(&(chunk.length as u32).to_le_bytes());
+
+ Ok((list_id, list.nodes.len() + nodes.len()))
+}
+
pub(crate) fn write_revisions(
source: &[u8],
- edit: impl FnOnce(&RevisionIndex<'_>) -> Result>>,
+ edit: impl FnOnce(&RevisionIndex<'_>) -> Result>,
) -> Result> {
let store = Store::parse(source)?;
let is_section = store.header.file_type == FileType::Section;
@@ -617,25 +661,63 @@ pub(crate) fn write_revisions(
index.validate_current()?;
let changes = edit(&index)?;
let mut output = source.to_vec();
- let maximum = store
+ let mut maximum = store
.transaction_fragments
.iter()
.flat_map(|fragment| fragment.entries.chunks_exact(8))
.map(|entry| u32::from_le_bytes(entry[..4].try_into().unwrap()))
.max()
.unwrap();
+ let mut allocate_list = || {
+ maximum = maximum.checked_add(1).ok_or(Error {
+ offset: 0,
+ message: "File-node list identities are exhausted",
+ })?;
+ Ok::<_, Error>(maximum)
+ };
let mut counts = Vec::new();
- for (space, mut replacements) in changes {
- let rid = *index
- .spaces
- .get(&space)
- .and_then(|space| space.labels.get(&(ExGuid::default(), 1)))
- .ok_or(Error {
- offset: 0,
- message: "Object space has no active default revision",
- })?;
- let mut revision = index.resolve(space, rid)?;
- let reachable = revision.reachable()?;
+ let mut root_nodes = Vec::new();
+ for (space, change) in changes {
+ let (rid, mut revision, mut replacements) = match change {
+ RevisionEdit::Update(objects) => {
+ let rid = *index
+ .spaces
+ .get(&space)
+ .and_then(|space| space.labels.get(&(ExGuid::default(), 1)))
+ .ok_or(Error {
+ offset: 0,
+ message: "Object space has no active default revision",
+ })?;
+ (Some(rid), index.resolve(space, rid)?, objects)
+ }
+ RevisionEdit::Create { roots, objects } => {
+ if !is_section || space.guid == [0; 16] || index.spaces.contains_key(&space) {
+ return Err(Error {
+ offset: 0,
+ message: "Choose a new object-space identity in a section file",
+ });
+ }
+ if roots.is_empty() || objects.is_empty() {
+ return Err(Error {
+ offset: 0,
+ message: "New object space needs roots and objects",
+ });
+ }
+ (
+ None,
+ crate::ResolvedRevision {
+ roots,
+ objects: BTreeMap::new(),
+ },
+ objects,
+ )
+ }
+ };
+ let reachable = if rid.is_some() {
+ revision.reachable()?
+ } else {
+ BTreeSet::new()
+ };
for (id, replacement) in &replacements {
if let Some(object) = revision.objects.get(id) {
if !reachable.contains(id) {
@@ -769,11 +851,10 @@ pub(crate) fn write_revisions(
}
// Native cold-open fails on long dependency chains; cap their depth at 512.
- let checkpoint = std::iter::successors(Some(rid), |id| {
- index.spaces[&space].revisions[id].dependency
- })
- .nth(511)
- .is_some();
+ let checkpoint = rid.is_none()
+ || std::iter::successors(rid, |id| index.spaces[&space].revisions[id].dependency)
+ .nth(511)
+ .is_some();
let selected: Vec<_> = revision
.objects
.iter()
@@ -821,13 +902,25 @@ pub(crate) fn write_revisions(
};
let mut start = Vec::new();
new_rid.encode(&mut start);
- if checkpoint { ExGuid::default() } else { rid }.encode(&mut start);
+ if checkpoint {
+ ExGuid::default()
+ } else {
+ rid.unwrap()
+ }
+ .encode(&mut start);
if !is_section {
start.extend_from_slice(&0_u64.to_le_bytes());
}
start.extend_from_slice(&1_u32.to_le_bytes());
start.extend_from_slice(&0_u16.to_le_bytes());
- let mut manifest = vec![node(if is_section { 0x1e } else { 0x1b }, None, &start)?];
+ let mut manifest = Vec::new();
+ if rid.is_none() {
+ let mut payload = Vec::new();
+ space.encode(&mut payload);
+ payload.extend_from_slice(&0_u32.to_le_bytes());
+ manifest.push(node(0x14, None, &payload)?);
+ }
+ manifest.push(node(if is_section { 0x1e } else { 0x1b }, None, &start)?);
for (table, objects) in groups {
let mut payload = Vec::new();
let mut group = if is_section {
@@ -941,14 +1034,7 @@ pub(crate) fn write_revisions(
}
if is_section {
group.push(node(0xb8, None, &[])?);
- let group_id = u32::try_from(counts.len())
- .ok()
- .and_then(|n| n.checked_add(1))
- .and_then(|n| maximum.checked_add(n))
- .ok_or(Error {
- offset: 0,
- message: "File-node list identities are exhausted",
- })?;
+ let group_id = allocate_list()?;
let chunk = append_list(&mut output, group_id, &group)?;
counts.push((group_id, group.len()));
manifest.push(node(0xb0, Some(Reference::NodeList(chunk)), &payload)?);
@@ -979,47 +1065,51 @@ pub(crate) fn write_revisions(
}
}
manifest.push(node(0x1c, None, &[])?);
- let root = store.list(store.header.root)?;
- let space_node = root
- .nodes
- .iter()
- .find(|node| node.id == 8 && node.fields(&store).exguid() == Ok(space))
- .ok_or(Error {
- offset: 0,
- message: "Object space is absent from the root list",
- })?;
- let space_list = space_node.referenced_list(&store)?;
- let revision_node = space_list.iter().rfind(|node| node.id == 0x10).unwrap();
- let Some(Reference::NodeList(manifest_reference)) = revision_node.reference else {
- unreachable!()
- };
- let revision_list = store.list(manifest_reference)?;
- let last_fragment = *revision_list.fragments.last().unwrap();
- let list_start = usize::try_from(last_fragment.offset).unwrap();
- let list_id =
- u32::from_le_bytes(source[list_start + 8..list_start + 12].try_into().unwrap());
- let manifest_chunk = append_list(&mut output, list_id, &manifest)?;
- let manifest_start = usize::try_from(manifest_chunk.offset).unwrap();
- let sequence = u32::try_from(revision_list.fragments.len()).map_err(|_| Error {
- offset: list_start,
- message: "File-node fragment sequences are exhausted",
- })?;
- output[manifest_start + 12..manifest_start + 16].copy_from_slice(&sequence.to_le_bytes());
- let last_node = revision_list.nodes.last().unwrap();
- let node_header = u32::from_le_bytes(
- source[last_node.offset..last_node.offset + 4]
- .try_into()
- .unwrap(),
- );
- let nodes_end = last_node.offset + usize::try_from((node_header >> 10) & 0x1fff).unwrap();
- let tail = list_start + usize::try_from(last_fragment.length).unwrap() - 20;
- if tail - nodes_end >= 4 {
- output[nodes_end..nodes_end + 4].copy_from_slice(&node(0xff, None, &[])?);
+ if rid.is_none() {
+ let list_id = allocate_list()?;
+ let chunk = append_list(&mut output, list_id, &manifest)?;
+ counts.push((list_id, manifest.len()));
+ let mut payload = Vec::new();
+ space.encode(&mut payload);
+ let nodes = vec![
+ node(0xc, None, &payload)?,
+ node(0x10, Some(Reference::NodeList(chunk)), &[])?,
+ ];
+ let list_id = allocate_list()?;
+ let chunk = append_list(&mut output, list_id, &nodes)?;
+ counts.push((list_id, nodes.len()));
+ root_nodes.push(node(8, Some(Reference::NodeList(chunk)), &payload)?);
+ } else {
+ let root = store.list(store.header.root)?;
+ let space_node = root
+ .nodes
+ .iter()
+ .find(|node| node.id == 8 && node.fields(&store).exguid() == Ok(space))
+ .ok_or(Error {
+ offset: 0,
+ message: "Object space is absent from the root list",
+ })?;
+ let space_list = space_node.referenced_list(&store)?;
+ let revision_node = space_list.iter().rfind(|node| node.id == 0x10).unwrap();
+ let Some(Reference::NodeList(manifest_reference)) = revision_node.reference else {
+ unreachable!()
+ };
+ let revision_list = store.list(manifest_reference)?;
+ counts.push(append_fragment(
+ source,
+ &mut output,
+ revision_list,
+ &manifest,
+ )?);
}
- output[tail..tail + 8].copy_from_slice(&manifest_chunk.offset.to_le_bytes());
- output[tail + 8..tail + 12].copy_from_slice(&(manifest_chunk.length as u32).to_le_bytes());
-
- counts.push((list_id, revision_list.nodes.len() + manifest.len()));
+ }
+ if !root_nodes.is_empty() {
+ counts.push(append_fragment(
+ source,
+ &mut output,
+ store.list(store.header.root)?,
+ &root_nodes,
+ )?);
}
if counts.is_empty() {
return Ok(source.to_vec());
diff --git a/crates/onestore/src/write/tests/multi.rs b/crates/onestore/src/write/tests/multi.rs
index 191a5d88846f581bba983d8c2eac71cb0fad0e70..e3bec7129975a4c305656a2b0fdfda4fcda071c9 100644
--- a/crates/onestore/src/write/tests/multi.rs
+++ b/crates/onestore/src/write/tests/multi.rs
@@ -1,7 +1,7 @@
use crate::{
ExGuid, RevisionIndex, Store,
document::{Document, Kind},
- write::{PropertyObject, write_revisions},
+ write::{PropertyObject, RevisionEdit, write_revisions},
};
use std::collections::BTreeMap;
@@ -90,7 +90,10 @@ fn nest(source: &[u8]) -> Vec {
object.set(&[(0x24001c20, &children)])?;
replacements.insert(root, object);
changes.insert(index.root, replacements);
- Ok(changes)
+ Ok(changes
+ .into_iter()
+ .map(|(sid, objects)| (sid, RevisionEdit::Update(objects)))
+ .collect())
})
.unwrap()
}
@@ -213,7 +216,7 @@ fn empty_batches_and_invalid_spaces_do_not_produce_an_edit() {
assert!(
write_revisions(SOURCE, |_| Ok(BTreeMap::from([(
ExGuid::default(),
- BTreeMap::new()
+ RevisionEdit::Update(BTreeMap::new())
)])))
.is_err()
);
@@ -247,7 +250,10 @@ fn repeated_multi_space_edits_cross_counter_carries_and_checkpoint_each_space()
object.set(&[(property, &step.to_le_bytes())])?;
changes.insert(*sid, BTreeMap::from([(id, object)]));
}
- Ok(changes)
+ Ok(changes
+ .into_iter()
+ .map(|(sid, objects)| (sid, RevisionEdit::Update(objects)))
+ .collect())
})
.unwrap();
let store = Store::parse(&written).unwrap();
diff --git a/crates/onestore/tests/page.rs b/crates/onestore/tests/page.rs
new file mode 100644
index 0000000000000000000000000000000000000000..f2ecc7d5c779d76dbca635b7252f8a0a01fa7393
--- /dev/null
+++ b/crates/onestore/tests/page.rs
@@ -0,0 +1,356 @@
+use onestore::{
+ ExGuid, Insertion, PageCreation, PreparedEdit, RevisionIndex, Store,
+ document::{Document, Kind},
+};
+
+#[path = "support/current.rs"]
+mod current;
+#[path = "support/disk.rs"]
+mod disk;
+
+const SOURCE: &[u8] =
+ include_bytes!("../../../corpus/page-lifecycle/03-renamed/notebook/Lifecycle.one");
+
+#[test]
+fn native_section_accepts_empty_and_titled_pages_with_preserved_history() {
+ let original = Store::parse(SOURCE).unwrap();
+ let old_index = RevisionIndex::parse(&original).unwrap();
+ let original_pages = Document::parse(&old_index).unwrap().pages().unwrap();
+ let mut expected = original_pages.clone();
+ let mut source = SOURCE.to_vec();
+ for (ordinal, title) in [None, Some(""), Some("New 🦋 é"), Some("New 🦋 é")]
+ .into_iter()
+ .enumerate()
+ {
+ let before = (ordinal == 1).then_some(original_pages[0].0);
+ let intent = PageCreation::new(before, title, "Rust author").unwrap();
+ let serialized = serde_json::to_vec(&intent).unwrap();
+ let retained = serde_json::from_slice(&serialized).unwrap();
+ assert_eq!(intent, retained);
+ let prepared = PreparedEdit::create_page(&source, &retained).unwrap();
+ let store = Store::parse(prepared.as_bytes()).unwrap();
+ assert_eq!(
+ store.header.transaction_count,
+ original.header.transaction_count + ordinal as u32 + 1
+ );
+ assert_eq!(store.header.file_id, original.header.file_id);
+ let index = RevisionIndex::parse(&store).unwrap();
+ index.validate_current().unwrap();
+ let document = Document::parse(&index).unwrap();
+ let at = if ordinal == 1 { 0 } else { expected.len() };
+ expected.insert(at, (intent.space(), intent.object()));
+ assert_eq!(document.pages().unwrap(), expected);
+ let view = &document.spaces[&intent.space()];
+ let view = &view.revisions[&view.contexts[&ExGuid::default()]];
+ assert!(view.nodes[&intent.object()].children.is_empty());
+ assert_eq!(
+ view.nodes[&intent.object()].structure.len(),
+ usize::from(title.is_some())
+ );
+ let Kind::Metadata {
+ title: actual,
+ level,
+ } = &view.nodes[&view.roots[&2]].kind
+ else {
+ panic!()
+ };
+ assert_eq!(actual.as_deref(), Some(title.unwrap_or_default()));
+ assert_eq!(*level, Some(1));
+ if let Some(text) = intent.title_object() {
+ let Kind::RichText {
+ text, boilerplate, ..
+ } = &view.nodes[&text].kind
+ else {
+ panic!()
+ };
+ assert_eq!(text, title.unwrap());
+ assert!(!boilerplate);
+ }
+ for (sid, space) in &old_index.spaces {
+ for rid in space.revisions.keys() {
+ assert_eq!(
+ format!("{:?}", old_index.resolve(*sid, *rid).unwrap()),
+ format!("{:?}", index.resolve(*sid, *rid).unwrap())
+ );
+ }
+ if *sid != old_index.root {
+ assert_eq!(space.labels, index.spaces[sid].labels);
+ }
+ }
+ assert!(PreparedEdit::create_page(prepared.as_bytes(), &intent).is_err());
+ source = prepared.as_bytes().to_vec();
+ }
+ if let Some(output) = std::env::var_os("ONESTORE_PAGE_CREATION_OUTPUT") {
+ std::fs::create_dir(&output).unwrap();
+ std::fs::write(std::path::Path::new(&output).join("Lifecycle.one"), &source).unwrap();
+ }
+}
+
+#[test]
+fn created_pages_support_title_edits_and_body_insertion() {
+ let mut source = onestore::create_section("pages.one", "Original", "Author").unwrap();
+ for title in [None, Some(""), Some("Explicit 🦋 é")] {
+ let intent = PageCreation::new(None, title, "Author").unwrap();
+ source = PreparedEdit::create_page(&source, &intent)
+ .unwrap()
+ .as_bytes()
+ .to_vec();
+ let insertion =
+ Insertion::outline(intent.object(), 36.0, 36.0, "Body 🦀 é", "Author").unwrap();
+ source = PreparedEdit::insert(&source, intent.space(), &insertion)
+ .unwrap()
+ .as_bytes()
+ .to_vec();
+ let store = Store::parse(&source).unwrap();
+ let index = RevisionIndex::parse(&store).unwrap();
+ let document = Document::parse(&index).unwrap();
+ let view = &document.spaces[&intent.space()];
+ let view = &view.revisions[&view.contexts[&ExGuid::default()]];
+ let Kind::Metadata { title: actual, .. } = &view.nodes[&view.roots[&2]].kind else {
+ panic!()
+ };
+ assert_eq!(
+ actual.as_deref(),
+ Some(title.filter(|s| !s.is_empty()).unwrap_or("Body 🦀 é"))
+ );
+ if let Some(text) = intent.title_object() {
+ source = PreparedEdit::text(
+ &source,
+ intent.space(),
+ text,
+ 0..title.unwrap().encode_utf16().count() as u32,
+ "Renamed",
+ )
+ .unwrap()
+ .as_bytes()
+ .to_vec();
+ let store = Store::parse(&source).unwrap();
+ let index = RevisionIndex::parse(&store).unwrap();
+ let document = Document::parse(&index).unwrap();
+ let view = &document.spaces[&intent.space()];
+ let view = &view.revisions[&view.contexts[&ExGuid::default()]];
+ let Kind::Metadata { title, .. } = &view.nodes[&view.roots[&2]].kind else {
+ panic!()
+ };
+ assert_eq!(title.as_deref(), Some("Renamed"));
+ }
+ }
+}
+
+#[test]
+fn invalid_page_intents_and_nonleading_anchors_reject_before_publication() {
+ for title in ["a\nb", "\r", "\0", "\u{fffc}", "\u{fddf}"] {
+ assert!(PageCreation::new(None, Some(title), "Author").is_err());
+ }
+ assert!(PageCreation::new(None, None, "\0").is_err());
+ assert!(PageCreation::new(Some(ExGuid::default()), None, "Author").is_err());
+ let source = include_bytes!("../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one");
+ let store = Store::parse(source).unwrap();
+ let index = RevisionIndex::parse(&store).unwrap();
+ let pages = Document::parse(&index).unwrap().pages().unwrap();
+ let intent = PageCreation::new(Some(pages[4].0), Some("Child"), "Author").unwrap();
+ assert!(PreparedEdit::create_page(source, &intent).is_err());
+ let mut value = serde_json::to_value(PageCreation::new(None, None, "Author").unwrap()).unwrap();
+ value["guid"] = serde_json::to_value([0_u8; 16]).unwrap();
+ let invalid: PageCreation = serde_json::from_value(value.clone()).unwrap();
+ assert!(PreparedEdit::create_page(source, &invalid).is_err());
+ value["unknown"] = true.into();
+ assert!(serde_json::from_value::(value).is_err());
+}
+
+#[test]
+fn new_space_and_section_entry_publish_as_one_complete_action() {
+ let source = onestore::create_section("pages.one", "Original", "Author").unwrap();
+ let intent = PageCreation::new(None, Some("New 🦋 é"), "Author").unwrap();
+ let prepared = PreparedEdit::create_page(&source, &intent).unwrap();
+ let old = current::current(&source);
+ let new = current::current(prepared.as_bytes());
+ assert_eq!(new.len(), old.len() + 1);
+ for write_limit in [1, 17, 4096] {
+ let mut complete = disk::Disk {
+ visible: source.clone(),
+ durable: source.clone(),
+ operation: 0,
+ fail_at: None,
+ write_limit,
+ random: 1954,
+ };
+ prepared.commit(&mut complete).unwrap();
+ assert_eq!(complete.durable, prepared.as_bytes());
+ for fail_at in 1..=complete.operation {
+ let mut interrupted = disk::Disk {
+ visible: source.clone(),
+ durable: source.clone(),
+ operation: 0,
+ fail_at: Some(fail_at),
+ write_limit,
+ random: 1954 + fail_at as u64,
+ };
+ prepared.commit(&mut interrupted).unwrap_err();
+ let observed = current::current(&interrupted.durable);
+ assert!(
+ observed == old || observed == new,
+ "{write_limit}:{fail_at}"
+ );
+ }
+ }
+}
+
+#[test]
+fn repeated_page_creation_crosses_root_fragments_counters_and_section_checkpoints() {
+ let mut source = onestore::create_section("PageStress.one", "Original", "Author").unwrap();
+ let original = source.clone();
+ let original_store = Store::parse(&original).unwrap();
+ let original_index = RevisionIndex::parse(&original_store).unwrap();
+ let mut expected = Document::parse(&original_index).unwrap().pages().unwrap();
+ let mut checkpoints = 0;
+ for step in 1..=520 {
+ let title = format!("Page {} 🦋 é", step % 7);
+ let intent = PageCreation::new(
+ None,
+ (step % 2 != 0).then_some(title.as_str()),
+ "Stress author",
+ )
+ .unwrap();
+ let prepared = PreparedEdit::create_page(&source, &intent).unwrap();
+ let mut unpublished = prepared.as_bytes().to_vec();
+ unpublished[96..100].copy_from_slice(&source[96..100]);
+ let old_store = Store::parse(&unpublished).unwrap();
+ let old_index = RevisionIndex::parse(&old_store).unwrap();
+ assert_eq!(
+ Document::parse(&old_index).unwrap().pages().unwrap(),
+ expected
+ );
+ let store = Store::parse(prepared.as_bytes()).unwrap();
+ assert_eq!(store.header.root, original_store.header.root);
+ assert_eq!(
+ store.header.transaction_count,
+ original_store.header.transaction_count + step
+ );
+ let index = RevisionIndex::parse(&store).unwrap();
+ assert_eq!(
+ index.spaces.len(),
+ original_index.spaces.len() + step as usize
+ );
+ expected.push((intent.space(), intent.object()));
+ assert_eq!(Document::parse(&index).unwrap().pages().unwrap(), expected);
+ let section = &index.spaces[&index.root];
+ let rid = section.labels[&(ExGuid::default(), 1)];
+ checkpoints += usize::from(section.revisions[&rid].dependency.is_none());
+ for (sid, space) in &original_index.spaces {
+ for rid in space.revisions.keys() {
+ assert_eq!(
+ format!("{:?}", original_index.resolve(*sid, *rid).unwrap()),
+ format!("{:?}", index.resolve(*sid, *rid).unwrap())
+ );
+ }
+ }
+ source = prepared.as_bytes().to_vec();
+ }
+ assert_eq!(checkpoints, 1);
+ if let Some(output) = std::env::var_os("ONESTORE_PAGE_STRESS_OUTPUT") {
+ std::fs::create_dir(&output).unwrap();
+ std::fs::write(
+ std::path::Path::new(&output).join("PageStress.one"),
+ &source,
+ )
+ .unwrap();
+ }
+}
+
+#[test]
+fn native_changes_on_created_pages_accept_rust_followups() {
+ let initial = include_bytes!(
+ "../../../corpus/page-lifecycle/creation/native/cold/notebook/Lifecycle.one"
+ );
+ let store = Store::parse(initial).unwrap();
+ let index = RevisionIndex::parse(&store).unwrap();
+ let document = Document::parse(&index).unwrap();
+ let mut targets = Vec::new();
+ for (sid, _) in document.pages().unwrap() {
+ let space = &document.spaces[&sid];
+ let view = &space.revisions[&space.contexts[&ExGuid::default()]];
+ let mut title = None;
+ let mut body = None;
+ for (id, node) in &view.nodes {
+ if let Kind::RichText { text, .. } = &node.kind {
+ if text == "New 🦋 é" {
+ title = Some(*id);
+ }
+ if text.starts_with("Native body ") {
+ body = Some(*id);
+ }
+ }
+ }
+ if let (Some(title), Some(body)) = (title, body) {
+ targets.push((sid, title, body));
+ }
+ }
+ assert_eq!(targets.len(), 2);
+ let mut source = initial.to_vec();
+ for (sid, title, body) in targets {
+ for (object, prefix) in [(body, "Rust + "), (title, "Reviewed ")] {
+ source = PreparedEdit::text(&source, sid, object, 0..0, prefix)
+ .unwrap()
+ .as_bytes()
+ .to_vec();
+ }
+ let store = Store::parse(&source).unwrap();
+ let index = RevisionIndex::parse(&store).unwrap();
+ let document = Document::parse(&index).unwrap();
+ let view = &document.spaces[&sid];
+ let view = &view.revisions[&view.contexts[&ExGuid::default()]];
+ assert!(
+ matches!(&view.nodes[&title].kind, Kind::RichText { text, .. } if text == "Reviewed New 🦋 é")
+ );
+ assert!(
+ matches!(&view.nodes[&body].kind, Kind::RichText { text, .. } if text.starts_with("Rust + Native body "))
+ );
+ assert!(
+ matches!(&view.nodes[&view.roots[&2]].kind, Kind::Metadata { title: Some(title), .. } if title == "Reviewed New 🦋 é")
+ );
+ }
+ if let Some(output) = std::env::var_os("ONESTORE_PAGE_FOLLOWUP_OUTPUT") {
+ std::fs::create_dir(&output).unwrap();
+ std::fs::write(std::path::Path::new(&output).join("Lifecycle.one"), &source).unwrap();
+ }
+}
+
+#[test]
+fn page_creation_preserves_native_features_and_file_data() {
+ for source in [
+ include_bytes!("../../../corpus/native-ink/20260905-ui/notebook/synthetic.one").as_slice(),
+ include_bytes!("../../../corpus/native-external-assets/notebook/synthetic.one").as_slice(),
+ include_bytes!("../../../corpus/m6/native-features-01/notebook/Features.one").as_slice(),
+ ] {
+ let before = Store::parse(source).unwrap();
+ let old = RevisionIndex::parse(&before).unwrap();
+ let original = Document::parse(&old).unwrap();
+ let intent = PageCreation::new(None, Some("Preserved features"), "Author").unwrap();
+ let prepared = PreparedEdit::create_page(source, &intent).unwrap();
+ let after = Store::parse(prepared.as_bytes()).unwrap();
+ let new = RevisionIndex::parse(&after).unwrap();
+ let document = Document::parse(&new).unwrap();
+ assert_eq!(
+ document.pages().unwrap().len(),
+ original.pages().unwrap().len() + 1
+ );
+ for (sid, space) in &old.spaces {
+ for rid in space.revisions.keys() {
+ let original = old.resolve(*sid, *rid).unwrap();
+ let retained = new.resolve(*sid, *rid).unwrap();
+ assert_eq!(format!("{original:?}"), format!("{retained:?}"));
+ for object in original.objects.values() {
+ if let Some(onestore::FileDataReference::Internal(id)) =
+ object.file_reference().unwrap()
+ {
+ assert_eq!(before.file_data(id).unwrap(), after.file_data(id).unwrap());
+ }
+ }
+ }
+ if *sid != old.root {
+ assert_eq!(space.labels, new.spaces[sid].labels);
+ }
+ }
+ }
+}
diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml
index 05afdb31cbee19b3952f956d79943754d5acb87a..13a8e908866d6a6d86414023b19f09314deaeb60 100644
--- a/fuzz/Cargo.toml
+++ b/fuzz/Cargo.toml
@@ -114,3 +114,10 @@ path = "fuzz_targets/offline_tree.rs"
test = false
doc = false
bench = false
+
+[[bin]]
+name = "page"
+path = "fuzz_targets/page.rs"
+test = false
+doc = false
+bench = false
diff --git a/fuzz/fuzz_targets/page.rs b/fuzz/fuzz_targets/page.rs
new file mode 100644
index 0000000000000000000000000000000000000000..36b68f756092379a905784e3ec5a61597cf04b08
--- /dev/null
+++ b/fuzz/fuzz_targets/page.rs
@@ -0,0 +1,138 @@
+#![no_main]
+use libfuzzer_sys::fuzz_target;
+use onestore::{
+ CommitState, ExGuid, Insertion, PageCreation, PreparedEdit, RevisionIndex, Store,
+ document::{Document, Kind},
+};
+use std::sync::LazyLock;
+
+#[path = "../../crates/onestore/tests/support/current.rs"]
+mod current;
+#[path = "../../crates/onestore/tests/support/disk.rs"]
+mod disk;
+
+static SOURCE: LazyLock> = LazyLock::new(|| {
+ onestore::create_section("pages.one", "Original 🦀 é 東京", "Author").unwrap()
+});
+
+fuzz_target!(|input: &[u8]| {
+ if let Ok(intent) = serde_json::from_slice::(input)
+ && let Ok(prepared) = PreparedEdit::create_page(&SOURCE, &intent)
+ {
+ current::current(prepared.as_bytes());
+ }
+ let mut persisted = SOURCE.clone();
+ let mut caches = std::array::from_fn::<_, 12, _>(|_| SOURCE.clone());
+ for step in input.chunks_exact(8).take(24) {
+ let actor = usize::from(step[0]) % caches.len();
+ if step[1] % 3 == 0 {
+ caches[actor].clone_from(&persisted);
+ }
+ let source = &caches[actor];
+ let store = Store::parse(source).unwrap();
+ let index = RevisionIndex::parse(&store).unwrap();
+ let document = Document::parse(&index).unwrap();
+ let mut pages = document.pages().unwrap();
+ let (sid, page) = pages[usize::from(step[2]) % pages.len()];
+ let space = &document.spaces[&sid];
+ let view = &space.revisions[&space.contexts[&ExGuid::default()]];
+ let text = ["", "Same title", "é 🦋 東京", " spaces "][usize::from(step[3]) % 4];
+ let titles: Vec<_> = view
+ .nodes
+ .iter()
+ .filter_map(|(id, node)| {
+ (node.extra[0].iter().any(|field| field.id == 0x88001cb4)
+ && matches!(
+ node.kind,
+ Kind::RichText {
+ boilerplate: false,
+ ..
+ }
+ ))
+ .then_some(*id)
+ })
+ .collect();
+ let (edit, title_update) = if step[1] & 8 != 0 && !titles.is_empty() {
+ let object = titles[0];
+ let Kind::RichText { text: original, .. } = &view.nodes[&object].kind else {
+ unreachable!()
+ };
+ (
+ PreparedEdit::text(
+ source,
+ sid,
+ object,
+ 0..original.encode_utf16().count() as u32,
+ text,
+ )
+ .unwrap(),
+ Some((sid, object, text)),
+ )
+ } else if step[1] & 16 != 0 {
+ let intent = Insertion::outline(page, 36.0, 36.0, text, "Page fuzz").unwrap();
+ (
+ PreparedEdit::insert(source, sid, &intent).unwrap(),
+ Some((sid, intent.text_object(), text)),
+ )
+ } else {
+ let before = (step[2] & 1 != 0).then_some(sid);
+ let title = (step[3] & 4 == 0).then_some(text);
+ let intent = PageCreation::new(before, title, "Page fuzz").unwrap();
+ let restored = serde_json::from_value(serde_json::to_value(&intent).unwrap()).unwrap();
+ assert_eq!(intent, restored);
+ let edit = PreparedEdit::create_page(source, &restored).unwrap();
+ let position = before.map_or(pages.len(), |sid| {
+ pages.iter().position(|p| p.0 == sid).unwrap()
+ });
+ pages.insert(position, (intent.space(), intent.object()));
+ (
+ edit,
+ intent
+ .title_object()
+ .map(|object| (intent.space(), object, text)),
+ )
+ };
+ let after_store = Store::parse(edit.as_bytes()).unwrap();
+ let after_index = RevisionIndex::parse(&after_store).unwrap();
+ let after_document = Document::parse(&after_index).unwrap();
+ assert_eq!(after_document.pages().unwrap(), pages);
+ if let Some((sid, object, text)) = title_update {
+ let space = &after_document.spaces[&sid];
+ let view = &space.revisions[&space.contexts[&ExGuid::default()]];
+ assert!(
+ matches!(&view.nodes[&object].kind, Kind::RichText { text: actual, .. } if actual == text)
+ );
+ }
+ for (sid, space) in &index.spaces {
+ let rid = space.labels[&(ExGuid::default(), 1)];
+ assert_eq!(
+ format!("{:?}", index.resolve(*sid, rid).unwrap()),
+ format!("{:?}", after_index.resolve(*sid, rid).unwrap())
+ );
+ }
+ let before = current::current(&persisted);
+ let after = current::current(edit.as_bytes());
+ let mut disk = disk::Disk {
+ visible: persisted.clone(),
+ durable: persisted.clone(),
+ operation: 0,
+ fail_at: (step[4] != 0).then_some(usize::from(u16::from_le_bytes([step[4], step[5]]))),
+ write_limit: if step[6] & 1 == 0 { 17 } else { 4096 },
+ random: u64::from(step[7]) + 1,
+ };
+ let result = edit.commit(&mut disk);
+ let observed = current::current(&disk.durable);
+ match result {
+ Ok(()) => assert_eq!(observed, after),
+ Err(error) => {
+ assert!(observed == before || observed == after);
+ match error.state {
+ CommitState::NotCommitted => assert_eq!(observed, before),
+ CommitState::Committed => assert_eq!(observed, after),
+ CommitState::Unknown => {}
+ }
+ }
+ }
+ persisted = disk.durable;
+ }
+});
diff --git a/tools/native/created-pages.ps1 b/tools/native/created-pages.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..b77bdeecbc2c657398800e2ec865444325fe0bae
--- /dev/null
+++ b/tools/native/created-pages.ps1
@@ -0,0 +1,35 @@
+param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost)
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+& "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost
+$app = New-Object -ComObject OneNote.Application
+$notebook = ''
+try {
+ $app.OpenHierarchy((Join-Path $Root 'notebook'), '', [ref]$notebook, 0)
+ $section = ''
+ $app.OpenHierarchy('Lifecycle.one', $notebook, [ref]$section, 0)
+ $deadline = [DateTime]::UtcNow.AddSeconds(30)
+ do {
+ $hierarchy = ''
+ $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
+ [xml]$tree = $hierarchy
+ $pages = @($tree.SelectNodes('//*[local-name()="Page"]'))
+ if ($pages.Count -eq 13) { break }
+ Start-Sleep -Milliseconds 200
+ } while ([DateTime]::UtcNow -lt $deadline)
+ $targets = @($pages | Where-Object { $_.GetAttribute('name').StartsWith('New ') })
+ if ($pages.Count -ne 13 -or $targets.Count -ne 2) { throw 'Expected thirteen pages and two created title controls.' }
+ $inputs = Join-Path $Root 'inputs'
+ [void](New-Item -ItemType Directory -Path $inputs)
+ for ($i = 0; $i -lt $targets.Count; $i++) {
+ $pageId = [Security.SecurityElement]::Escape($targets[$i].GetAttribute('ID'))
+ $xml = "Native body $i after Rust page creation."
+ [IO.File]::WriteAllText((Join-Path $inputs ("native-$i.xml")), $xml, [Text.Encoding]::UTF8)
+ $app.UpdatePageContent($xml, [DateTime]::MinValue, 1, $false)
+ }
+ $app.SyncHierarchy($notebook)
+} finally {
+ [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
+ [GC]::Collect()
+ [GC]::WaitForPendingFinalizers()
+}
diff --git a/tools/test_page_creation.py b/tools/test_page_creation.py
new file mode 100644
index 0000000000000000000000000000000000000000..5a0c777299347384402c658db116e7fa874901d5
--- /dev/null
+++ b/tools/test_page_creation.py
@@ -0,0 +1,48 @@
+import json
+from pathlib import Path
+import subprocess
+from tempfile import TemporaryDirectory
+import unittest
+
+from document_model import EXPORTER, ordered_pages, walk
+from verify_page_creation import compare
+
+FIXTURE = Path(__file__).resolve().parent.parent / 'corpus/page-lifecycle/creation'
+
+
+class PageCreationTest(unittest.TestCase):
+ def test_created_pages_and_both_writers_survive_native_cold_reopens(self):
+ for source, capture in [('candidate', 'cold'), ('native/notebook', 'native/cold'), ('followup', 'followup/cold')]:
+ with self.subTest(source=source):
+ compare(FIXTURE / source, FIXTURE / capture)
+
+ def test_duplicate_titles_keep_distinct_pages_through_native_and_rust_edits(self):
+ with TemporaryDirectory() as temporary:
+ identities = None
+ for ordinal, path in enumerate(('candidate', 'native/cold/notebook', 'followup/cold/notebook')):
+ output = Path(temporary) / str(ordinal)
+ subprocess.run([EXPORTER, FIXTURE / path / 'Lifecycle.one', output], check=True)
+ document = json.loads((output / 'document.json').read_text())
+ pages = list(ordered_pages(document))
+ self.assertEqual(len(pages), 13)
+ current = [(sid, page) for sid, _, _, page in pages]
+ self.assertEqual(len(set(current)), 13)
+ if identities is None:
+ identities = current
+ self.assertEqual(identities, current)
+ for index, (_, _, view, page) in enumerate(pages[-2:]):
+ title = ('Reviewed ' if ordinal == 2 else '') + 'New 🦋 é'
+ self.assertEqual(view['nodes'][view['roots']['2']]['kind']['title'], title)
+ expected = [title]
+ if ordinal:
+ expected.append(('Rust + ' if ordinal == 2 else '') + f'Native body {index} after Rust page creation.')
+ self.assertEqual([node['kind']['text'] for _, node in walk(view, page)
+ if node['kind']['type'] == 'RichText'], expected)
+ for at, titled in [(0, True), (10, False)]:
+ _, _, view, page = pages[at]
+ self.assertEqual(bool(view['nodes'][page]['structure']), titled)
+ self.assertEqual(view['nodes'][page]['children'], [])
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tools/verify_page_creation.py b/tools/verify_page_creation.py
new file mode 100644
index 0000000000000000000000000000000000000000..01d3d6a533550f36ba45d1cdd2ee7d44ea2eb208
--- /dev/null
+++ b/tools/verify_page_creation.py
@@ -0,0 +1,73 @@
+"""Compare created pages with native XML and cold-reopened object graphs."""
+import argparse
+from copy import deepcopy
+import json
+from pathlib import Path
+import runpy
+import shutil
+import subprocess
+from tempfile import TemporaryDirectory
+from uuid import UUID
+
+from document_model import EXPORTER, ordered_pages, view
+
+compare_document = runpy.run_path(str(Path(__file__).with_name('verify-document.py')))['compare']
+
+
+def compare(source, capture):
+ files = list(source.glob('*.one'))
+ assert len(files) == 1
+ with TemporaryDirectory() as temporary:
+ temporary = Path(temporary)
+ native = temporary / 'native/read'
+ shutil.copytree(capture / 'read', native)
+ notebook = temporary / 'notebook'
+ notebook.mkdir()
+ (notebook / files[0].name).symlink_to(files[0].resolve())
+ compare_document(notebook, native)
+ models = []
+ for ordinal, file in enumerate((files[0], capture / 'notebook' / files[0].name)):
+ output = temporary / str(ordinal)
+ subprocess.run([EXPORTER, file, output], check=True)
+ models.append(json.loads((output / 'document.json').read_text()))
+ before, after = models
+ pages = list(ordered_pages(before))
+ assert [(sid, page) for sid, _, _, page in pages] == [
+ (sid, page) for sid, _, _, page in ordered_pages(after)]
+ assert before['root'] == after['root']
+ assert before['spaces'].keys() == after['spaces'].keys()
+ filled = 0
+ for sid in before['spaces']:
+ _, original = view(before, sid)
+ _, observed = view(after, sid)
+ expected = deepcopy(original['nodes'])
+ if sid == before['root']:
+ for series_id in original['nodes'][original['roots']['1']]['children']:
+ series = expected[series_id]
+ existing = [p for p in series['extra'][0] if p['id'] == 0x24003442]
+ added = [p for p in observed['nodes'][series_id]['extra'][0] if p['id'] == 0x24003442]
+ if existing or not added:
+ continue
+ copies = []
+ for page_sid in series['spaces']:
+ (_, _, page_view, _), = [p for p in pages if p[0] == page_sid]
+ # Native uses the space GUID when it differs from the page-node GUID.
+ guid = UUID(page_sid.split('}')[0].strip('{')).bytes_le
+ salt = UUID('22a8c031-3600-42ee-b714-d7acda2435e8').bytes_le
+ metadata = '{' + str(UUID(bytes_le=bytes(a ^ b for a, b in zip(guid, salt, strict=True)))).upper() + '},1'
+ assert metadata not in expected
+ expected[metadata] = page_view['nodes'][page_view['roots']['2']]
+ copies.append(metadata)
+ filled += 1
+ series['extra'][0].append({'id': 0x24003442, 'value': {'Objects': copies}})
+ assert original['roots'] == observed['roots']
+ assert expected == observed['nodes'], sid
+ print(f'Passed: {len(pages)} ordered pages and preserved active graphs; {filled} missing section metadata copies filled natively')
+
+
+if __name__ == '__main__':
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('source', type=Path)
+ parser.add_argument('capture', type=Path)
+ args = parser.parse_args()
+ compare(args.source, args.capture)