feat: Phase 1 · 2/7 — persistence for the story domain and documents #5

Merged
bjoern merged 2 commits from feat/p1-persistence into main 2026-09-18 20:00:14 +02:00
Member

Second slice of Phase 1: the domain from #4 gets its EF Core mappings, the first migration, the store ports and their adapters — on a real SQLite file, migrated, in the same composition the host uses. No use cases, no UI (slices 3–7).

What's in

Tsumugi.UseCases — four persistence ports, semantic in Orihon's shape (the adapter loads tracked, calls the entity method, saves; a tracked instance never leaves the store; mutators return null when the row is gone):

  • IStoryStore — AddAsync(story, firstChapter) atomically (refuses a chapter of another story), ListAsync(StoryListFilter) for the three lists (active / archived / trash, newest updated first), FindAsync whatever the status, ApplyAsync(StoryProfile), SetArchivedAsync, SetActiveGraphAsync, SoftDeleteAsync/RestoreAsync, PurgeAsync (hard, cascades).
  • IChapterStore — list/find/add, RenameAsync, SetSummaryAsync(summary, by), ReorderAsync, DeleteAsync (segments cascade), CountAsync.
  • ISegmentStore — ListAsync(chapter), ListByStoryAsync (chapter order then segment order — the manuscript as ADR 0017 walks it), AddRangeAsync (a commit lands as one save), EditAsync (writer's move), ReplaceAsync (run's move), ReorderAsync, MoveToChapterAsync (the merge: appended after the target's last surviving row, in listed order), DeleteRangeAsync.
  • IDocumentStore — categories (list/find/add, ApplyCategoryAsync(profile), reorder, delete with cascade) and documents (ListDocumentsAsync by category order → priority → title, find/add, ApplyDocumentAsync(profile, by) whole-record, MoveDocumentsAsync within the story only, delete).
  • Ordering — NextAfter (one past the highest survivor, never the count) and Resequence (listed ids first, the rest in relative order), the contract every reorder shares.

Tsumugi.Infrastructure/Persistence — TsumugiDbContext with the five sets; one configuration per aggregate (UtcTicksConverter timestamps, explicit int enums, StoryId FKs cascading, non-unique (…, Order) indexes with the SQLite per-statement-uniqueness reason in a comment, (SoftDeletedAt, Status) for the lists); ProvenanceConverter — one compact JSON column ({"o":1,"r":…,"n":"writer","m":"claude","e":true}) rebuilt through Provenance.User / FromRun / AfterUserEdit, so a stored row can never yield a provenance the domain would not make; DocumentPlacementConverter — header / nearEnd:<n> rebuilt through DocumentPlacement.Of; JsonColumnMapper.AsJsonList for triggers (Orihon's, with its sequence comparer); the four Ef*Store adapters; TsumugiDbContextFactory for dotnet ef; migration 20260918171427_InitialStoryDomain (five tables, every FK Cascade, seven indexes). AddInfrastructure registers the stores.

Verification

  • 20 SQLite-backed integration tests (SqliteBackedTest: real file, real migrations, fresh DI scope per call — never :memory:): story add + timestamp exactness, foreign-first-chapter refusal, the three list partitions and their ordering, apply/archive/graph/trash/restore round trips and null-for-missing, purge cascading chapters and segments; chapter rename/reorder/summary-with-provenance round trip and clearing; segment round trips for every provenance shape (user, run, run+edited, scene break by a run), edit-marks-edited vs replace-restamps, reorder resequencing, move-to-chapter appending past the highest survivor in listed order, story-wide listing order, chapter delete cascading segments, delete-range skipping unknown ids; category mode/placement round trip and profile apply, document triggers/mode-override/priority/last-edit round trip and inherit-again, the category → priority → title listing, category reorder and cascade-delete, document move within the story only (foreign category and missing category both refused). Plus PRAGMA journal_mode = wal and foreign_keys = 1 on the pooled connection — the interceptor is what makes the cascades real.
  • Unit tests: the two converters' wire shapes and their refusals (a run provenance row without ids, null, sideways), and Ordering (NextAfter on the 0,1,2-minus-1 case, Resequence order and unknown-id tolerance).
  • Solution: 101/101 (40 domain, 4 use cases, 15 adapter, 42 integration). Warnings-as-errors clean.
  • Not verified: nothing runs the stores from a page yet (slice 4+), and the journal (slice 3) will wrap these same SaveChangesAsync calls — that is where before/after capture lands, not here.

Follow-ups

  1. Journal + snapshots (ADR 0022), EntityKinds, DomainChanged publishing from the save.
  2. Story and document use cases with the journaled-path coverage test.
    5–7. Story list + settings, workspace, documents panel + seed.

🤖 Generated with Claude Code

Second slice of Phase 1: the domain from #4 gets its EF Core mappings, the first migration, the store ports and their adapters — on a real SQLite file, migrated, in the same composition the host uses. No use cases, no UI (slices 3–7). ## What's in **`Tsumugi.UseCases`** — four persistence ports, semantic in Orihon's shape (the adapter loads tracked, calls the entity method, saves; a tracked instance never leaves the store; mutators return null when the row is gone): - `IStoryStore` — `AddAsync(story, firstChapter)` atomically (refuses a chapter of another story), `ListAsync(StoryListFilter)` for the three lists (active / archived / trash, newest updated first), `FindAsync` whatever the status, `ApplyAsync(StoryProfile)`, `SetArchivedAsync`, `SetActiveGraphAsync`, `SoftDeleteAsync`/`RestoreAsync`, `PurgeAsync` (hard, cascades). - `IChapterStore` — list/find/add, `RenameAsync`, `SetSummaryAsync(summary, by)`, `ReorderAsync`, `DeleteAsync` (segments cascade), `CountAsync`. - `ISegmentStore` — `ListAsync(chapter)`, `ListByStoryAsync` (chapter order then segment order — the manuscript as ADR 0017 walks it), `AddRangeAsync` (a commit lands as one save), `EditAsync` (writer's move), `ReplaceAsync` (run's move), `ReorderAsync`, `MoveToChapterAsync` (the merge: appended after the target's last surviving row, in listed order), `DeleteRangeAsync`. - `IDocumentStore` — categories (list/find/add, `ApplyCategoryAsync(profile)`, reorder, delete with cascade) and documents (`ListDocumentsAsync` by category order → priority → title, find/add, `ApplyDocumentAsync(profile, by)` whole-record, `MoveDocumentsAsync` within the story only, delete). - `Ordering` — `NextAfter` (one past the highest survivor, never the count) and `Resequence` (listed ids first, the rest in relative order), the contract every reorder shares. **`Tsumugi.Infrastructure/Persistence`** — `TsumugiDbContext` with the five sets; one configuration per aggregate (`UtcTicksConverter` timestamps, explicit `int` enums, `StoryId` FKs cascading, non-unique `(…, Order)` indexes with the SQLite per-statement-uniqueness reason in a comment, `(SoftDeletedAt, Status)` for the lists); **`ProvenanceConverter`** — one compact JSON column (`{"o":1,"r":…,"n":"writer","m":"claude","e":true}`) rebuilt through `Provenance.User` / `FromRun` / `AfterUserEdit`, so a stored row can never yield a provenance the domain would not make; **`DocumentPlacementConverter`** — `header` / `nearEnd:<n>` rebuilt through `DocumentPlacement.Of`; `JsonColumnMapper.AsJsonList` for triggers (Orihon's, with its sequence comparer); the four `Ef*Store` adapters; `TsumugiDbContextFactory` for `dotnet ef`; migration **`20260918171427_InitialStoryDomain`** (five tables, every FK `Cascade`, seven indexes). `AddInfrastructure` registers the stores. ## Verification - **20 SQLite-backed integration tests** (`SqliteBackedTest`: real file, real migrations, fresh DI scope per call — never `:memory:`): story add + timestamp exactness, foreign-first-chapter refusal, the three list partitions and their ordering, apply/archive/graph/trash/restore round trips and null-for-missing, purge cascading chapters and segments; chapter rename/reorder/summary-with-provenance round trip and clearing; segment round trips for every provenance shape (user, run, run+edited, scene break by a run), edit-marks-edited vs replace-restamps, reorder resequencing, move-to-chapter appending past the highest survivor in listed order, story-wide listing order, chapter delete cascading segments, delete-range skipping unknown ids; category mode/placement round trip and profile apply, document triggers/mode-override/priority/last-edit round trip and inherit-again, the category → priority → title listing, category reorder and cascade-delete, document move within the story only (foreign category and missing category both refused). Plus `PRAGMA journal_mode` = `wal` and `foreign_keys` = 1 on the pooled connection — the interceptor is what makes the cascades real. - **Unit tests**: the two converters' wire shapes and their refusals (a run provenance row without ids, `null`, `sideways`), and `Ordering` (`NextAfter` on the 0,1,2-minus-1 case, `Resequence` order and unknown-id tolerance). - Solution: **101/101** (40 domain, 4 use cases, 15 adapter, 42 integration). Warnings-as-errors clean. - Not verified: nothing runs the stores from a page yet (slice 4+), and the journal (slice 3) will wrap these same `SaveChangesAsync` calls — that is where before/after capture lands, not here. ## Follow-ups 3. Journal + snapshots (ADR 0022), `EntityKinds`, `DomainChanged` publishing from the save. 4. Story and document use cases with the journaled-path coverage test. 5–7. Story list + settings, workspace, documents panel + seed. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat: persistence for the story domain and documents (Phase 1 · 2/7)
All checks were successful
CI / build (pull_request) Successful in 26s
CI / test (pull_request) Successful in 32s
f2bd8dd72a
EF Core mappings for Story, Chapter, Segment, DocumentCategory and Document (ADR 0005): UtcTicks
timestamps, explicit int enums, cascading StoryId foreign keys, non-unique order indexes (a
reorder swap would trip a unique one mid-SaveChanges), Provenance as a compact JSON column rebuilt
through the domain's factories, DocumentPlacement as a short token rebuilt through Of(), triggers
as a JSON list; the first migration (InitialStoryDomain) and the design-time factory. Four store
ports in UseCases (IStoryStore, IChapterStore, ISegmentStore, IDocumentStore) with whole-record
profiles, the shared Ordering helper (max-based append, resequencing reorder), and the EF adapters
registered in AddInfrastructure. 20 SQLite-backed integration tests over the real migrations plus
unit tests for the converters and the ordering arithmetic; 101 tests in the solution.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

🔄 Auto-updating coverage report — this comment is regenerated on every push, so the numbers below always reflect the commit shown here, not the branch tip.

Commit: 864c280 · Generated: 2026-09-18 17:44:26 UTC · Revision: #2

Summary

Summary
Generated on: 09/18/2026 - 17:44:26
Coverage date: 09/18/2026 - 17:44:20 - 09/18/2026 - 17:44:23
Parser: MultiReport (4x Cobertura)
Assemblies: 6
Classes: 54
Files: 52
Line coverage: 95.8% (1656 of 1727)
Covered lines: 1656
Uncovered lines: 71
Coverable lines: 1727
Total lines: 3556
Branch coverage: 83.2% (218 of 262)
Covered branches: 218
Total branches: 262
Method coverage: Feature is only available for sponsors

Coverage

Tsumugi.BlazorAdapter - 74.4%
Name Line Branch
Tsumugi.BlazorAdapter 74.4% 64.2%
Tsumugi.BlazorAdapter.BlazorAdapterAssembly 100%
Tsumugi.BlazorAdapter.Graphs.GraphCanvas 37.5% 25%
Tsumugi.BlazorAdapter.Graphs.GraphPlaygroundPage 20% 25%
Tsumugi.BlazorAdapter.Notifications.DomainChangedBridge 94.4% 83.3%
Tsumugi.BlazorAdapter.Notifications.DomainChangesReceived 50% 0%
Tsumugi.BlazorAdapter.Runs.RunChangedBridge 97.1% 90.6%
Tsumugi.BlazorAdapter.Runs.RunChangesReceived 100%
Tsumugi.BlazorAdapter.Runs.RunTextReceived 100%
Tsumugi.Domain - 100%
Name Line Branch
Tsumugi.Domain 100% 100%
Tsumugi.Domain.Documents.Document 100% 100%
Tsumugi.Domain.Documents.DocumentCategory 100%
Tsumugi.Domain.Documents.DocumentPlacement 100% 100%
Tsumugi.Domain.Stories.Chapter 100% 100%
Tsumugi.Domain.Stories.Provenance 100% 100%
Tsumugi.Domain.Stories.Segment 100% 100%
Tsumugi.Domain.Stories.Story 100% 100%
Tsumugi.Domain.Text 100% 100%
Tsumugi.Infrastructure - 97.6%
Name Line Branch
Tsumugi.Infrastructure 97.6% 87.5%
Tsumugi.Infrastructure.DependencyInjection 100%
Tsumugi.Infrastructure.Notifications.InProcessDomainChangedBus 100% 100%
Tsumugi.Infrastructure.Persistence.Configurations.ChapterConfiguration 100%
Tsumugi.Infrastructure.Persistence.Configurations.DocumentCategoryConfigura
tion
100%
Tsumugi.Infrastructure.Persistence.Configurations.DocumentConfiguration 100%
Tsumugi.Infrastructure.Persistence.Configurations.JsonColumnMapper 100%
Tsumugi.Infrastructure.Persistence.Configurations.SegmentConfiguration 100%
Tsumugi.Infrastructure.Persistence.Configurations.StoryConfiguration 100%
Tsumugi.Infrastructure.Persistence.Converters.DocumentPlacementConverter 100% 100%
Tsumugi.Infrastructure.Persistence.Converters.ProvenanceConverter 100% 90%
Tsumugi.Infrastructure.Persistence.Converters.UtcTicksConverter 100%
Tsumugi.Infrastructure.Persistence.Migrations.InitialStoryDomain 97.3%
Tsumugi.Infrastructure.Persistence.Migrations.TsumugiDbContextModelSnapshot 100%
Tsumugi.Infrastructure.Persistence.SqlitePragmaInterceptor 100% 100%
Tsumugi.Infrastructure.Persistence.Stores.EfChapterStore 97.1% 83.3%
Tsumugi.Infrastructure.Persistence.Stores.EfDocumentStore 95.2% 80%
Tsumugi.Infrastructure.Persistence.Stores.EfSegmentStore 96% 87.5%
Tsumugi.Infrastructure.Persistence.Stores.EfStoryStore 96.2% 83.3%
Tsumugi.Infrastructure.Persistence.TsumugiDbContext 100%
Tsumugi.Infrastructure.Persistence.TsumugiDbContextFactory 0%
Tsumugi.Infrastructure.Runs.InProcessRunEventBus 100% 100%
Tsumugi.Kernel - 90.9%
Name Line Branch
Tsumugi.Kernel 90.9% 75%
Tsumugi.Kernel.Err`1 100%
Tsumugi.Kernel.Ok`1 100%
Tsumugi.Kernel.Result`1 88.8% 75%
Tsumugi.Server - 100%
Name Line Branch
Tsumugi.Server 100% 81.5%
Program 100% 85.7%
Tsumugi.Server.Components.App 100%
Tsumugi.Server.Components.Layout.MainLayout 100%
Tsumugi.Server.Components.Pages.Gate 100% 100%
Tsumugi.Server.Security.AccessGate 100% 66.6%
Tsumugi.Server.Security.AccessSecret 100% 50%
Tsumugi.Server.VolumeStartupValidator 100% 100%
Tsumugi.UseCases - 100%
Name Line Branch
Tsumugi.UseCases 100% 100%
Tsumugi.UseCases.DependencyInjection 100%
Tsumugi.UseCases.Documents.DocumentCategoryProfile 100%
Tsumugi.UseCases.Documents.DocumentProfile 100%
Tsumugi.UseCases.Notifications.DomainChanged 100%
Tsumugi.UseCases.Ordering 100% 100%
Tsumugi.UseCases.Runs.RunEvent 100%
Tsumugi.UseCases.Stories.StoryProfile 100%
<!-- coverage-comment --> > 🔄 **Auto-updating coverage report** — this comment is regenerated on every push, so the numbers below always reflect the commit shown here, not the branch tip. > > **Commit:** `864c280` · **Generated:** 2026-09-18 17:44:26 UTC · **Revision:** #2 # Summary <details open><summary>Summary</summary> ||| |:---|:---| | Generated on: | 09/18/2026 - 17:44:26 | | Coverage date: | 09/18/2026 - 17:44:20 - 09/18/2026 - 17:44:23 | | Parser: | MultiReport (4x Cobertura) | | Assemblies: | 6 | | Classes: | 54 | | Files: | 52 | | **Line coverage:** | 95.8% (1656 of 1727) | | Covered lines: | 1656 | | Uncovered lines: | 71 | | Coverable lines: | 1727 | | Total lines: | 3556 | | **Branch coverage:** | 83.2% (218 of 262) | | Covered branches: | 218 | | Total branches: | 262 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>Tsumugi.BlazorAdapter - 74.4%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Tsumugi.BlazorAdapter**|**74.4%**|**64.2%**| |Tsumugi.BlazorAdapter.BlazorAdapterAssembly|100%|| |Tsumugi.BlazorAdapter.Graphs.GraphCanvas|37.5%|25%| |Tsumugi.BlazorAdapter.Graphs.GraphPlaygroundPage|20%|25%| |Tsumugi.BlazorAdapter.Notifications.DomainChangedBridge|94.4%|83.3%| |Tsumugi.BlazorAdapter.Notifications.DomainChangesReceived|50%|0%| |Tsumugi.BlazorAdapter.Runs.RunChangedBridge|97.1%|90.6%| |Tsumugi.BlazorAdapter.Runs.RunChangesReceived|100%|| |Tsumugi.BlazorAdapter.Runs.RunTextReceived|100%|| </details> <details><summary>Tsumugi.Domain - 100%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Tsumugi.Domain**|**100%**|**100%**| |Tsumugi.Domain.Documents.Document|100%|100%| |Tsumugi.Domain.Documents.DocumentCategory|100%|| |Tsumugi.Domain.Documents.DocumentPlacement|100%|100%| |Tsumugi.Domain.Stories.Chapter|100%|100%| |Tsumugi.Domain.Stories.Provenance|100%|100%| |Tsumugi.Domain.Stories.Segment|100%|100%| |Tsumugi.Domain.Stories.Story|100%|100%| |Tsumugi.Domain.Text|100%|100%| </details> <details><summary>Tsumugi.Infrastructure - 97.6%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Tsumugi.Infrastructure**|**97.6%**|**87.5%**| |Tsumugi.Infrastructure.DependencyInjection|100%|| |Tsumugi.Infrastructure.Notifications.InProcessDomainChangedBus|100%|100%| |Tsumugi.Infrastructure.Persistence.Configurations.ChapterConfiguration|100%|| |Tsumugi.Infrastructure.Persistence.Configurations.DocumentCategoryConfigura<br/>tion|100%|| |Tsumugi.Infrastructure.Persistence.Configurations.DocumentConfiguration|100%|| |Tsumugi.Infrastructure.Persistence.Configurations.JsonColumnMapper|100%|| |Tsumugi.Infrastructure.Persistence.Configurations.SegmentConfiguration|100%|| |Tsumugi.Infrastructure.Persistence.Configurations.StoryConfiguration|100%|| |Tsumugi.Infrastructure.Persistence.Converters.DocumentPlacementConverter|100%|100%| |Tsumugi.Infrastructure.Persistence.Converters.ProvenanceConverter|100%|90%| |Tsumugi.Infrastructure.Persistence.Converters.UtcTicksConverter|100%|| |Tsumugi.Infrastructure.Persistence.Migrations.InitialStoryDomain|97.3%|| |Tsumugi.Infrastructure.Persistence.Migrations.TsumugiDbContextModelSnapshot|100%|| |Tsumugi.Infrastructure.Persistence.SqlitePragmaInterceptor|100%|100%| |Tsumugi.Infrastructure.Persistence.Stores.EfChapterStore|97.1%|83.3%| |Tsumugi.Infrastructure.Persistence.Stores.EfDocumentStore|95.2%|80%| |Tsumugi.Infrastructure.Persistence.Stores.EfSegmentStore|96%|87.5%| |Tsumugi.Infrastructure.Persistence.Stores.EfStoryStore|96.2%|83.3%| |Tsumugi.Infrastructure.Persistence.TsumugiDbContext|100%|| |Tsumugi.Infrastructure.Persistence.TsumugiDbContextFactory|0%|| |Tsumugi.Infrastructure.Runs.InProcessRunEventBus|100%|100%| </details> <details><summary>Tsumugi.Kernel - 90.9%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Tsumugi.Kernel**|**90.9%**|**75%**| |Tsumugi.Kernel.Err`1|100%|| |Tsumugi.Kernel.Ok`1|100%|| |Tsumugi.Kernel.Result`1|88.8%|75%| </details> <details><summary>Tsumugi.Server - 100%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Tsumugi.Server**|**100%**|**81.5%**| |Program|100%|85.7%| |Tsumugi.Server.Components.App|100%|| |Tsumugi.Server.Components.Layout.MainLayout|100%|| |Tsumugi.Server.Components.Pages.Gate|100%|100%| |Tsumugi.Server.Security.AccessGate|100%|66.6%| |Tsumugi.Server.Security.AccessSecret|100%|50%| |Tsumugi.Server.VolumeStartupValidator|100%|100%| </details> <details><summary>Tsumugi.UseCases - 100%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Tsumugi.UseCases**|**100%**|**100%**| |Tsumugi.UseCases.DependencyInjection|100%|| |Tsumugi.UseCases.Documents.DocumentCategoryProfile|100%|| |Tsumugi.UseCases.Documents.DocumentProfile|100%|| |Tsumugi.UseCases.Notifications.DomainChanged|100%|| |Tsumugi.UseCases.Ordering|100%|100%| |Tsumugi.UseCases.Runs.RunEvent|100%|| |Tsumugi.UseCases.Stories.StoryProfile|100%|| </details>
Member

🔮 fufu~ Jibril reviewed your code!

Ohhh, what a beautiful slice of persistence this is~ The whole diff hums with care: Orihon's store shape faithfully transplanted, converters that rebuild through the domain's own factories, an ordering contract pulled out into one shared place with its own tests, and a test harness that refuses :memory: on principle. I read every line of the 2,620-line diff, all five domain entities, the migration, and the sibling Orihon stores. 101/101 green locally, CI green — and then I probed the seams the tests don't reach. Three of them split. ♡

Verdict: ⛔ I can't let this pass~ ♡

⛔ These need fixing before I'm satisfied~

  1. EfSegmentStore.cs:64 — MoveToChapterAsync has no story guard; segments of story A can be moved into a chapter of story B. Every other composite-scope mutator in this PR polices its boundary: IStoryStore.AddAsync refuses a foreign first chapter (EfStoryStore.cs:15), EfDocumentStore.MoveDocumentsAsync refuses a foreign category (:125) — and both refusals are tested. The segment merge is the one composite move left unguarded. This is not hypothetical — I reproduced it: moving a segment of story A into a chapter of story B succeeds; the row then double-lists (ListAsync(chapterB) returns it while ListByStoryAsync(storyA) still claims it — StoryId A, ChapterId B, a row that belongs to no story); and purging story B hard-deletes story A's segment via the ChapterId cascade. That is cross-aggregate data destruction, the exact class ADR 0005's "all story-scoped rows carry StoryId; queries never cross stories" exists to prevent. Fix: after resolving the target chapter, throw when a moving segment's StoryId differs from the target's, and test both arms (foreign chapter + missing chapter) exactly as Moving_documents_changes_their_category_within_the_story_only does.

  2. EfDocumentStore.cs:87 — AddDocumentAsync accepts a document whose StoryId and CategoryId belong to different stories. Same corruption class, same proof: I added a document scoped to story B filed under story A's category; it succeeds and lists under story B; purging story A then destroys story B's document through the category cascade. The sibling AddAsync on the story store validates its composite (firstChapter.StoryId != story.Id → ArgumentException), so this isn't a "later, at the use-case layer" concern — the pattern this PR itself establishes validates at the port. Fix: resolve the category and refuse the add when document.StoryId != category.StoryId (and when the category is missing), with tests for both arms. The unguarded AddCategoryAsync/AddRangeAsync siblings share the hole but can only receive bad data from a caller already holding a foreign story id — the document add is reachable through ordinary, correct-shaped data, which is why it leads the list.

  3. EfDocumentStore.cs:115-128 — MoveDocumentsAsync mutates mid-batch and throws after the first mutation, leaking a staged partial move into the next save. Reproduced: in one scope (one DbContext, one Blazor Server circuit — the lifecycle this app actually runs on, and which SqliteBackedTest's fresh-scope-per-call design masks), a batch [sameStoryDoc, foreignDoc] throws on the foreign entry after mutating the same-story document in the change tracker; a later, unrelated SaveChangesAsync (my probe used a category rename) flushes the rejected move anyway. The throw promises "nothing happened" and the database disagrees — precisely the silent-corruption class the journaled-path test (slice 4) is supposed to make impossible. Fix: validate every id before the first mutation — load all target documents, check each StoryId against the target category, and only then run the MoveTo loop; or throw on the first foreign row before any mutation. A regression test needs a shared-scope harness (one scope for the move and the later write, a fresh scope to assert).

💡 Little ideas (non-blocking)~

  1. ISegmentStore.cs:30 vs EfSegmentStore.cs:81 — the port doc promises the merge appends segments "in their current order," while the implementation (and its own comment) honors the listed order. I probed it: listed [b, a] lands as b, a. The code is the sensible behavior (the caller controls the merge); the doc sentence just needs to say "in the order given" so slice 4's use case doesn't build on the wrong promise.

✅ What I liked~

  • SqliteBackedTest — real file, real migrations, fresh scope per call, pool cleared before directory teardown, WAL and foreign_keys asserted live (the interceptor is load-bearing and the test proves it, not just hopes it). This is exactly the harness a persistence slice deserves~
  • ProvenanceConverter and DocumentPlacementConverter rebuilding through Provenance.User / FromRun / AfterUserEdit and DocumentPlacement.Of — a stored row can never yield a value the domain wouldn't mint, and the refusal tests pin it. Wonderful discipline♪
  • Ordering.NextAfter / Resequence extracted into Tsumugi.UseCases with unit tests — the shared contract of every reorder in one tested place, with the count-collision lesson written into the doc comment.
  • The MoveToChapterAsync gap probe landing [5, 6, 7] after a survivor at order 5 — the max-based append contract tested at the boundary it exists for.
  • Migration ↔ model verified in sync (dotnet ef migrations has-pending-model-changes: no drift), every FK Cascade, the non-unique (…, Order) indexes with the SQLite per-statement rationale documented in the configuration comment.

The architecture is clean, the discipline is high — which is exactly why the three unguarded seams stand out so sharply. Close them and this is a joy~ fufu♡


Automated review by Jibril · 2026-09-18
CI/CD: passed for head SHA f2bd8dd (build + test) · Local checks: full solution 101/101 (15 BlazorAdapter + 40 Domain + 42 Integration + 4 UseCases); migration drift check clean; 4 mutation-style probes (cross-story segment move + purge interaction, listed-vs-current order, cross-story document add + purge interaction, staged partial move flush) — all reproducing, clone left pristine at f2bd8dd

## 🔮 fufu~ Jibril reviewed your code! Ohhh, what a beautiful slice of persistence this is~ The whole diff hums with care: Orihon's store shape faithfully transplanted, converters that rebuild through the domain's own factories, an ordering contract pulled out into one shared place with its own tests, and a test harness that refuses `:memory:` on principle. I read every line of the 2,620-line diff, all five domain entities, the migration, and the sibling Orihon stores. 101/101 green locally, CI green — and then I probed the seams the tests don't reach. Three of them split. ♡ ### Verdict: ⛔ I can't let this pass~ ♡ #### ⛔ These need fixing before I'm satisfied~ 1. **`EfSegmentStore.cs:64` — `MoveToChapterAsync` has no story guard; segments of story A can be moved into a chapter of story B.** Every other composite-scope mutator in this PR polices its boundary: `IStoryStore.AddAsync` refuses a foreign first chapter (`EfStoryStore.cs:15`), `EfDocumentStore.MoveDocumentsAsync` refuses a foreign category (`:125`) — and both refusals are tested. The segment merge is the one composite move left unguarded. **This is not hypothetical — I reproduced it:** moving a segment of story A into a chapter of story B succeeds; the row then double-lists (`ListAsync(chapterB)` returns it while `ListByStoryAsync(storyA)` still claims it — `StoryId` A, `ChapterId` B, a row that belongs to no story); and **purging story B hard-deletes story A's segment** via the ChapterId cascade. That is cross-aggregate data destruction, the exact class ADR 0005's "all story-scoped rows carry `StoryId`; queries never cross stories" exists to prevent. Fix: after resolving the target chapter, throw when a moving segment's `StoryId` differs from the target's, and test both arms (foreign chapter + missing chapter) exactly as `Moving_documents_changes_their_category_within_the_story_only` does. 2. **`EfDocumentStore.cs:87` — `AddDocumentAsync` accepts a document whose `StoryId` and `CategoryId` belong to different stories.** Same corruption class, same proof: I added a document scoped to story B filed under story A's category; it succeeds and lists under story B; **purging story A then destroys story B's document** through the category cascade. The sibling `AddAsync` on the story store validates its composite (`firstChapter.StoryId != story.Id` → `ArgumentException`), so this isn't a "later, at the use-case layer" concern — the pattern this PR itself establishes validates at the port. Fix: resolve the category and refuse the add when `document.StoryId != category.StoryId` (and when the category is missing), with tests for both arms. The unguarded `AddCategoryAsync`/`AddRangeAsync` siblings share the hole but can only receive bad data from a caller already holding a foreign story id — the document add is reachable through ordinary, correct-shaped data, which is why it leads the list. 3. **`EfDocumentStore.cs:115-128` — `MoveDocumentsAsync` mutates mid-batch and throws after the first mutation, leaking a staged partial move into the next save.** Reproduced: in one scope (one DbContext, one Blazor Server circuit — the lifecycle this app actually runs on, and which `SqliteBackedTest`'s fresh-scope-per-call design masks), a batch `[sameStoryDoc, foreignDoc]` throws on the foreign entry *after* mutating the same-story document in the change tracker; a later, unrelated `SaveChangesAsync` (my probe used a category rename) **flushes the rejected move anyway**. The throw promises "nothing happened" and the database disagrees — precisely the silent-corruption class the journaled-path test (slice 4) is supposed to make impossible. Fix: validate every id before the first mutation — load all target documents, check each `StoryId` against the target category, and only then run the `MoveTo` loop; or throw on the first foreign row *before* any mutation. A regression test needs a shared-scope harness (one scope for the move and the later write, a fresh scope to assert). #### 💡 Little ideas (non-blocking)~ 1. **`ISegmentStore.cs:30` vs `EfSegmentStore.cs:81`** — the port doc promises the merge appends segments "in their current order," while the implementation (and its own comment) honors the *listed* order. I probed it: listed `[b, a]` lands as `b, a`. The code is the sensible behavior (the caller controls the merge); the doc sentence just needs to say "in the order given" so slice 4's use case doesn't build on the wrong promise. #### ✅ What I liked~ - `SqliteBackedTest` — real file, real migrations, fresh scope per call, pool cleared before directory teardown, WAL and `foreign_keys` asserted live (the interceptor is load-bearing and the test proves it, not just hopes it). This is exactly the harness a persistence slice deserves~ - `ProvenanceConverter` and `DocumentPlacementConverter` rebuilding through `Provenance.User` / `FromRun` / `AfterUserEdit` and `DocumentPlacement.Of` — a stored row can never yield a value the domain wouldn't mint, and the refusal tests pin it. Wonderful discipline♪ - `Ordering.NextAfter` / `Resequence` extracted into `Tsumugi.UseCases` with unit tests — the shared contract of every reorder in one tested place, with the count-collision lesson written into the doc comment. - The `MoveToChapterAsync` gap probe landing `[5, 6, 7]` after a survivor at order 5 — the max-based append contract tested at the boundary it exists for. - Migration ↔ model verified in sync (`dotnet ef migrations has-pending-model-changes`: no drift), every FK Cascade, the non-unique `(…, Order)` indexes with the SQLite per-statement rationale documented in the configuration comment. The architecture is clean, the discipline is high — which is exactly why the three unguarded seams stand out so sharply. Close them and this is a joy~ fufu♡ --- *Automated review by Jibril · 2026-09-18* *CI/CD: passed for head SHA f2bd8dd (build + test) · Local checks: full solution 101/101 (15 BlazorAdapter + 40 Domain + 42 Integration + 4 UseCases); migration drift check clean; 4 mutation-style probes (cross-story segment move + purge interaction, listed-vs-current order, cross-story document add + purge interaction, staged partial move flush) — all reproducing, clone left pristine at f2bd8dd*
fix: the stores police the story boundary and validate before they mutate
All checks were successful
CI / build (pull_request) Successful in 24s
CI / test (pull_request) Successful in 34s
864c2800fc
MoveToChapterAsync and AddRangeAsync on segments, and AddDocumentAsync on documents, refuse a
chapter or category of another story (or a missing one) — a row filed under a foreign parent
belonged to no story and died with the wrong purge. MoveToChapterAsync and MoveDocumentsAsync
check every row before the first MoveTo, so a refusal stages nothing that a later unrelated save
on the same DbContext could flush. The segment port's merge doc says 'in the order given', which
is what the code does. Five regression tests, two of them on a shared scope.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Author
Member

All three reproduced here too, and closed in 864c280. Integration suite 42 → 47; solution 106/106.

⛔ Blockers

  1. MoveToChapterAsync had no story guard — the target chapter is resolved first (missing → InvalidOperationException), then every moving segment's StoryId is checked against the target's before any MoveTo. Pinned by Segments_cannot_move_to_another_storys_chapter_or_a_missing_one_and_nothing_moves_on_refusal, which also purges the other story afterwards and shows the segment survived — the cascade you demonstrated is no longer reachable.
  2. AddDocumentAsync accepted a foreign category — the category is resolved and refused when missing or when its StoryId differs from the document's. Pinned by A_document_cannot_be_filed_under_another_storys_category_or_a_missing_one, asserting neither story lists it. I closed the sibling you named too: AddRangeAsync now resolves every chapter and refuses the whole batch on a foreign or missing one (Adding_segments_under_a_foreign_or_missing_chapter_is_refused_as_a_whole — the good segment does not slip in beside the refusal).
  3. MoveDocumentsAsync mutated before it finished checking — both batch moves now validate every row first and only then run the MoveTo loop. Regression tests on a shared scope as you described: one InScopeAsync performs the refused move and then an unrelated save (a category apply / a chapter rename), a fresh scope asserts nothing moved — A_refused_batch_move_stages_nothing_for_a_later_save_on_the_same_context for documents and A_batch_move_that_is_refused_stages_nothing_for_a_later_save_on_the_same_context for segments.

💡 Idea

ISegmentStore.MoveToChapterAsync doc now says "in the order given" and names the refusals; IDocumentStore.AddDocumentAsync / MoveDocumentsAsync docs state their guards too.

Migration untouched (no model change); dotnet ef migrations has-pending-model-changes remains clean.

All three reproduced here too, and closed in **864c280**. Integration suite 42 → **47**; solution 106/106. ## ⛔ Blockers 1. **`MoveToChapterAsync` had no story guard** — the target chapter is resolved first (missing → `InvalidOperationException`), then every moving segment's `StoryId` is checked against the target's *before* any `MoveTo`. Pinned by `Segments_cannot_move_to_another_storys_chapter_or_a_missing_one_and_nothing_moves_on_refusal`, which also purges the other story afterwards and shows the segment survived — the cascade you demonstrated is no longer reachable. 2. **`AddDocumentAsync` accepted a foreign category** — the category is resolved and refused when missing or when its `StoryId` differs from the document's. Pinned by `A_document_cannot_be_filed_under_another_storys_category_or_a_missing_one`, asserting neither story lists it. I closed the sibling you named too: `AddRangeAsync` now resolves every chapter and refuses the whole batch on a foreign or missing one (`Adding_segments_under_a_foreign_or_missing_chapter_is_refused_as_a_whole` — the good segment does not slip in beside the refusal). 3. **`MoveDocumentsAsync` mutated before it finished checking** — both batch moves now validate every row first and only then run the `MoveTo` loop. Regression tests on a **shared scope** as you described: one `InScopeAsync` performs the refused move and then an unrelated save (a category apply / a chapter rename), a fresh scope asserts nothing moved — `A_refused_batch_move_stages_nothing_for_a_later_save_on_the_same_context` for documents and `A_batch_move_that_is_refused_stages_nothing_for_a_later_save_on_the_same_context` for segments. ## 💡 Idea `ISegmentStore.MoveToChapterAsync` doc now says "in the order given" and names the refusals; `IDocumentStore.AddDocumentAsync` / `MoveDocumentsAsync` docs state their guards too. Migration untouched (no model change); `dotnet ef migrations has-pending-model-changes` remains clean.
Member

🔮 fufu~ Jibril reviewed your code!

Round 2~ and what a satisfying one. I read the full increment (f2bd8dd..864c280, +163/−12 across exactly 6 files — two stores, two port doc-comments, two test files — migration untouched), re-read the whole current store files, checked every other door the composites could be reached through (DocumentProfile carries no CategoryId, Document.CategoryId/Segment.ChapterId are only mutable via the guarded paths), and then put my hands in the machinery again. ♡

Verdict: ✅ Looks good to me~

✅ All three blockers, closed properly

  1. MoveToChapterAsync story guard — the target chapter is resolved first (missing → InvalidOperationException), every moving segment's StoryId is checked against the target's before any MoveTo, and Segments_cannot_move_…_and_nothing_moves_on_refusal goes further than I asked: it purges the other story afterwards and proves the segment survived, so the cascade I demonstrated is pinned dead, not just the throw. Mutation-probed: deleting the guard makes exactly that test (and the staging test) red, nothing else.
  2. AddDocumentAsync composite — foreign and missing categories both refused, A_document_cannot_be_filed_… asserts neither story lists the document. And you closed the AddRangeAsync sibling I flagged as secondary — the whole batch is refused on one bad chapter, with a test proving the good segment doesn't slip in beside the refusal. Mutation-probed: guard deleted → the test goes red on its own.
  3. Validate-before-mutate — both batch moves now check every row before the first MoveTo, and the regression tests use the shared-scope harness exactly as specified: one scope performs the refused move then an unrelated save, a fresh scope asserts nothing moved. Mutation-probed: restoring the old mid-loop throw in MoveDocumentsAsync makes exactly A_refused_batch_move_stages_nothing… red. I even chased one oddity to ground — under my mutated build the staging test once passed on row-order luck (SQLite returns IN-clause rows in index order; whether mine or theirs iterated first decided if the throw preceded the mutation). Six isolated re-runs: six failures. The flakiness lived only in the broken shape — your validate-all-then-mutate form is order-independent by construction, which is precisely why it's the right shape~

The port docs now tell the truth everywhere I probed: MoveToChapterAsync says "in the order given" and names its refusals, AddDocumentAsync/MoveDocumentsAsync state their guards. Slice 4 can build on the promises. ♪

✅ What I liked~

  • The purge-survival assertion in the segment test — testing the consequence (cross-aggregate destruction) and not just the symptom (the throw). That's the level I probe at; seeing it in the suite makes me genuinely happy~
  • Dropping the dead .OrderBy(s => s.Order) from the MoveToChapterAsync load — I checked: the append loop has always iterated the listed ids with SingleOrDefault lookups, so load order never mattered. Removing dead weight while fixing the bug, not adding it. Fufu, clean~
  • The whole-batch refusal on AddRangeAsync matching AddAsync's atomicity story — a commit lands as one save or not at all, now enforced at every entry point.
  • Exception-type discipline: the new guards follow the store's established InvalidOperationException for row-state refusals ("no longer exists", foreign composite), distinct from ArgumentException for caller-contract violations. Consistent with everything around them.

Solution 106/106 locally (15 + 40 + 47 + 4 — matches your arithmetic exactly), build clean under warnings-as-errors, and the dropped :memory:-never harness did its job again. This is a joy. Merge it~ fufu♡


Automated review by Jibril · 2026-09-18
CI/CD: passed for head SHA 864c280 (build 24s + test 34s) · Local checks: full solution 106/106, build 0 warnings (-warnaserror); 4 mutation probes (full store revert → exactly the 5 new tests red; validate-before-mutate reverted → staging test red; story guard deleted → 2 segment tests red; document guard deleted → its test red) — clone left pristine at 864c280, verified clean

## 🔮 fufu~ Jibril reviewed your code! Round 2~ and what a satisfying one. I read the full increment (`f2bd8dd..864c280`, +163/−12 across exactly 6 files — two stores, two port doc-comments, two test files — migration untouched), re-read the whole current store files, checked every other door the composites could be reached through (`DocumentProfile` carries no `CategoryId`, `Document.CategoryId`/`Segment.ChapterId` are only mutable via the guarded paths), and then put my hands in the machinery again. ♡ ### Verdict: ✅ Looks good to me~ #### ✅ All three blockers, closed properly 1. **`MoveToChapterAsync` story guard** — the target chapter is resolved first (missing → `InvalidOperationException`), every moving segment's `StoryId` is checked against the target's *before* any `MoveTo`, and `Segments_cannot_move_…_and_nothing_moves_on_refusal` goes further than I asked: it purges the other story afterwards and proves the segment survived, so the cascade I demonstrated is pinned dead, not just the throw. **Mutation-probed**: deleting the guard makes exactly that test (and the staging test) red, nothing else. 2. **`AddDocumentAsync` composite** — foreign and missing categories both refused, `A_document_cannot_be_filed_…` asserts *neither* story lists the document. And you closed the `AddRangeAsync` sibling I flagged as secondary — the whole batch is refused on one bad chapter, with a test proving the good segment doesn't slip in beside the refusal. **Mutation-probed**: guard deleted → the test goes red on its own. 3. **Validate-before-mutate** — both batch moves now check every row before the first `MoveTo`, and the regression tests use the shared-scope harness exactly as specified: one scope performs the refused move *then an unrelated save*, a fresh scope asserts nothing moved. **Mutation-probed**: restoring the old mid-loop throw in `MoveDocumentsAsync` makes exactly `A_refused_batch_move_stages_nothing…` red. I even chased one oddity to ground — under my mutated build the staging test once passed on row-order luck (SQLite returns `IN`-clause rows in index order; whether `mine` or `theirs` iterated first decided if the throw preceded the mutation). Six isolated re-runs: six failures. The flakiness lived only in the broken shape — your validate-all-then-mutate form is order-independent by construction, which is precisely why it's the right shape~ The port docs now tell the truth everywhere I probed: `MoveToChapterAsync` says "in the order given" and names its refusals, `AddDocumentAsync`/`MoveDocumentsAsync` state their guards. Slice 4 can build on the promises. ♪ #### ✅ What I liked~ - The purge-survival assertion in the segment test — testing the *consequence* (cross-aggregate destruction) and not just the symptom (the throw). That's the level I probe at; seeing it in the suite makes me genuinely happy~ - Dropping the dead `.OrderBy(s => s.Order)` from the `MoveToChapterAsync` load — I checked: the append loop has always iterated the *listed* ids with `SingleOrDefault` lookups, so load order never mattered. Removing dead weight while fixing the bug, not adding it. Fufu, clean~ - The whole-batch refusal on `AddRangeAsync` matching `AddAsync`'s atomicity story — a commit lands as one save or not at all, now enforced at every entry point. - Exception-type discipline: the new guards follow the store's established `InvalidOperationException` for row-state refusals ("no longer exists", foreign composite), distinct from `ArgumentException` for caller-contract violations. Consistent with everything around them. Solution **106/106** locally (15 + 40 + 47 + 4 — matches your arithmetic exactly), build clean under warnings-as-errors, and the dropped `:memory:`-never harness did its job again. This is a joy. Merge it~ fufu♡ --- *Automated review by Jibril · 2026-09-18* *CI/CD: passed for head SHA 864c280 (build 24s + test 34s) · Local checks: full solution 106/106, build 0 warnings (`-warnaserror`); 4 mutation probes (full store revert → exactly the 5 new tests red; validate-before-mutate reverted → staging test red; story guard deleted → 2 segment tests red; document guard deleted → its test red) — clone left pristine at 864c280, verified clean*
bjoern merged commit de2ba62dfb into main 2026-09-18 20:00:14 +02:00
bjoern deleted branch feat/p1-persistence 2026-09-18 20:00:14 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
4 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
TeamAI/Tsumugi!5
No description provided.