Reversibility: soft-delete + persistent change journal (ADR 0020, part 1) #5

Merged
bjoern merged 2 commits from feat/reversibility-journal into main 2026-07-09 16:06:44 +02:00
Member

The backbone the whole "AI acts freely, fix later" model rests on (ADR 0020): mutations auto-apply and every one is recorded so it can be reversed. This is part 1 of 2 — the data + recording half. The undo/redo replay engine is the immediately-following slice (it's the algorithmically tricky part and deserves its own review).

What's in it

Domain

  • ISoftDeletable + SoftDelete/Restore on Project, Entry, Link (kept per-entity — Entry is already a TPT base and the three are independent aggregates; a shared base would couple unrelated roots).
  • ChangeLogEntry + ChangeOperation {Create, Update, SoftDelete, Restore}.
  • Kernel.Unit for use cases that succeed without a value.

UseCases

  • IOperationContext — ambient operation id / origin / label. Within a Begin scope every save shares one operation id (so a user/agent action journals + undoes as a unit); otherwise each save stands alone under a default origin.
  • IChangeJournal read port + GetEntityHistory (the version-history / audit lens, ADR 0022's data source).
  • RemoveLink / RestoreLink — soft-delete is not gated; reversibility, not approval, is the safety net.

Infrastructure — the unit of work

  • KaguraDbContext journals every Added/Modified entity into ChangeLog on save, with before/after property-bag JSON snapshots. It snapshots before the save (so a caught unique-violation leaves no orphan journal rows) and persists the rows only after the domain save succeeds. Update vs SoftDelete vs Restore is derived from the IsDeleted transition.
  • Global soft-delete query filters on all three entities — a removed thing vanishes from queries and backlinks by default.
  • The link uniqueness index is now partial (WHERE IsDeleted = 0), so a removed edge can be re-created (its soft-deleted row stays as history) rather than being blocked by a ghost.
  • SlugExistsAsync ignores the filter, so a trashed project keeps its slug/asset-folder reserved.
  • Migration adds IsDeleted/DeletedAt to the three tables + the ChangeLog table, and rebuilds the link index as partial.

Tests — 81, all green (was 51; +30)

  • 71 unit: soft-delete/restore on each entity, RemoveLink/RestoreLink (incl. that a removed edge can be recreated — live-only uniqueness), plus the existing suites.
  • 10 integration over real SQLite: Create journaled with an after-snapshot + default origin; Update journaled with before and after; remove→restore journaled and toggling backlink visibility, with history reading back [Restore, SoftDelete, Create]; and an operation scope grouping two saves under one id + origin.

Verification

  • dotnet build (Debug + Release) — 0 warnings / 0 errors (warnings-as-errors).
  • dotnet test — 81/81 pass.
  • Fresh boot applies all three migrations (Projects, Entries, Links, ChangeLog) with the partial index, serves HTTP 200, clean log.

Not in this PR

  • ADR 0020 part 2 (next): the undo/redo replay engine — apply-inverse of an operation-id group, the linear stack + redo pointer, and the changed-since conflict warning. The before/after JSON snapshots are already being written for it.
  • DomainChanged cross-session refresh (ADR 0016): rides with the UI/Fluxor slice where it has a real subscriber (a publisher with no consumer would be dead code).
  • Entity-level soft-delete use cases (project/node delete buttons) land with the UI.

🤖 Generated with Claude Code

The backbone the whole "AI acts freely, fix later" model rests on (ADR 0020): mutations auto-apply and every one is recorded so it can be reversed. This is **part 1 of 2** — the data + recording half. The undo/redo *replay* engine is the immediately-following slice (it's the algorithmically tricky part and deserves its own review). ## What's in it **Domain** - `ISoftDeletable` + `SoftDelete`/`Restore` on `Project`, `Entry`, `Link` (kept per-entity — `Entry` is already a TPT base and the three are independent aggregates; a shared base would couple unrelated roots). - `ChangeLogEntry` + `ChangeOperation` {Create, Update, SoftDelete, Restore}. - `Kernel.Unit` for use cases that succeed without a value. **UseCases** - `IOperationContext` — ambient operation id / origin / label. Within a `Begin` scope every save shares one operation id (so a user/agent action journals + undoes as a unit); otherwise each save stands alone under a default origin. - `IChangeJournal` read port + `GetEntityHistory` (the version-history / audit lens, ADR 0022's data source). - `RemoveLink` / `RestoreLink` — soft-delete is **not gated**; reversibility, not approval, is the safety net. **Infrastructure — the unit of work** - `KaguraDbContext` journals every `Added`/`Modified` entity into `ChangeLog` on save, with before/after **property-bag JSON** snapshots. It snapshots **before** the save (so a caught unique-violation leaves no orphan journal rows) and persists the rows only **after** the domain save succeeds. `Update` vs `SoftDelete` vs `Restore` is derived from the `IsDeleted` transition. - Global soft-delete **query filters** on all three entities — a removed thing vanishes from queries and backlinks by default. - The link uniqueness index is now **partial** (`WHERE IsDeleted = 0`), so a removed edge can be re-created (its soft-deleted row stays as history) rather than being blocked by a ghost. - `SlugExistsAsync` ignores the filter, so a trashed project keeps its slug/asset-folder reserved. - Migration adds `IsDeleted`/`DeletedAt` to the three tables + the `ChangeLog` table, and rebuilds the link index as partial. ## Tests — 81, all green (was 51; +30) - **71 unit**: soft-delete/restore on each entity, `RemoveLink`/`RestoreLink` (incl. that a removed edge can be recreated — live-only uniqueness), plus the existing suites. - **10 integration** over real SQLite: `Create` journaled with an after-snapshot + default origin; `Update` journaled with before **and** after; remove→restore journaled and **toggling backlink visibility**, with history reading back `[Restore, SoftDelete, Create]`; and an **operation scope grouping two saves under one id + origin**. ## Verification - `dotnet build` (Debug + Release) — 0 warnings / 0 errors (warnings-as-errors). - `dotnet test` — 81/81 pass. - Fresh boot applies all three migrations (`Projects`, `Entries`, `Links`, `ChangeLog`) with the partial index, serves HTTP 200, clean log. ## Not in this PR - **ADR 0020 part 2 (next):** the undo/redo replay engine — apply-inverse of an operation-id group, the linear stack + redo pointer, and the changed-since conflict warning. The before/after JSON snapshots are already being written for it. - **`DomainChanged` cross-session refresh (ADR 0016):** rides with the UI/Fluxor slice where it has a real subscriber (a publisher with no consumer would be dead code). - Entity-level soft-delete use cases (project/node delete buttons) land with the UI. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(journal): soft-delete + persistent change journal (ADR 0020, part 1)
All checks were successful
CI / build (pull_request) Successful in 11s
CI / test (pull_request) Successful in 15s
2afba7699e
The reversibility backbone: mutations auto-apply and are recorded so they can be
undone later. This slice lands the data + recording half; the undo/redo replay
engine is the next slice.

- Domain: ISoftDeletable + SoftDelete/Restore on Project/Entry/Link; ChangeLogEntry
  + ChangeOperation; Kernel Unit for void-ish results
- UseCases: IOperationContext (ambient operation id/origin/label) + IChangeJournal
  read port; GetEntityHistory; RemoveLink/RestoreLink (soft-delete is not gated —
  reversibility is the safety net)
- Infrastructure: KaguraDbContext journals every Added/Modified entity into
  ChangeLog on save (Create/Update/SoftDelete/Restore with before/after property-bag
  JSON), snapshotting BEFORE the save so a caught unique-violation leaves no orphan
  rows and persisting them only after the domain save succeeds; OperationContext +
  EfChangeJournal; global soft-delete query filters on all three entities; the link
  uniqueness index is now partial (live edges only) so a removed edge can be
  re-created; migration adds IsDeleted/DeletedAt + the ChangeLog table
- Tests: +30 (71 unit + 10 integration = 81). Real SQLite proves journaling of
  create/update/soft-delete/restore, that soft-deletes toggle backlink visibility,
  history ordering, and that an operation scope groups multiple saves under one id

Not here (next slice, ADR 0020 part 2): the undo/redo replay engine (apply-inverse
+ linear stack + changed-since conflict warning). DomainChanged cross-session
refresh (ADR 0016) rides with the UI/Fluxor slice where it has a consumer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Summary

Summary
Generated on: 07/09/2026 - 13:49:24
Coverage date: 07/09/2026 - 13:49:21 - 07/09/2026 - 13:49:22
Parser: MultiReport (2x Cobertura)
Assemblies: 4
Classes: 42
Files: 40
Line coverage: 94.8% (1281 of 1351)
Covered lines: 1281
Uncovered lines: 70
Coverable lines: 1351
Total lines: 2256
Branch coverage: 86.4% (121 of 140)
Covered branches: 121
Total branches: 140
Method coverage: Feature is only available for sponsors

Coverage

Kagura.Domain - 96.3%
Name Line Branch
Kagura.Domain 96.3% 82.6%
Kagura.Domain.Graph.Entry 100% 100%
Kagura.Domain.Graph.Link 100% 100%
Kagura.Domain.Graph.LinkRole 100% 100%
Kagura.Domain.Graph.LinkRoles 92.3%
Kagura.Domain.Journal.ChangeLogEntry 100%
Kagura.Domain.Projects.Project 100%
Kagura.Domain.Projects.Slug 100% 100%
System.Text.RegularExpressions.Generated 90.2% 72.2%
System.Text.RegularExpressions.Generated.<RegexGenerator_g>FE06CC341D340484
D04ADFED3A21D401C2764A1D17367E35BEB556CBB3B4B0B74__NonSlugChars_0
89.4% 75%
Kagura.Infrastructure - 94.5%
Name Line Branch
Kagura.Infrastructure 94.5% 84.7%
Kagura.Infrastructure.DependencyInjection 100%
Kagura.Infrastructure.Graph.EfGraphStore 95.5% 66.6%
Kagura.Infrastructure.Journal.EfChangeJournal 100%
Kagura.Infrastructure.Journal.OperationContext 100% 100%
Kagura.Infrastructure.Persistence.Configurations.ChangeLogEntryConfiguratio
n
100%
Kagura.Infrastructure.Persistence.Configurations.EntryConfiguration 100%
Kagura.Infrastructure.Persistence.Configurations.LinkConfiguration 100%
Kagura.Infrastructure.Persistence.Configurations.ProjectConfiguration 100%
Kagura.Infrastructure.Persistence.Converters.UtcTicksConverter 100%
Kagura.Infrastructure.Persistence.KaguraDbContext 84.6% 84.3%
Kagura.Infrastructure.Persistence.KaguraDbContextFactory 0%
Kagura.Infrastructure.Persistence.Migrations.AddGraphEntryAndLink 97.7%
Kagura.Infrastructure.Persistence.Migrations.AddSoftDeleteAndChangeLog 90.3%
Kagura.Infrastructure.Persistence.Migrations.InitialCreate 94.4%
Kagura.Infrastructure.Persistence.Migrations.KaguraDbContextModelSnapshot 100%
Kagura.Infrastructure.Projects.EfProjectStore 100%
Kagura.Kernel - 90%
Name Line Branch
Kagura.Kernel 90% 75%
Kagura.Kernel.Err`1 100%
Kagura.Kernel.Ok`1 100%
Kagura.Kernel.Result`1 87.5% 75%
Kagura.UseCases - 94.6%
Name Line Branch
Kagura.UseCases 94.6% 94.7%
Kagura.UseCases.DependencyInjection 100%
Kagura.UseCases.Graph.EdgeGroup 100%
Kagura.UseCases.Graph.GetNodeGraph 96.4% 83.3%
Kagura.UseCases.Graph.GraphEdgeView 85.7%
Kagura.UseCases.Graph.LinkNodes 100% 100%
Kagura.UseCases.Graph.NodeGraphView 100%
Kagura.UseCases.Graph.NodeSummary 100%
Kagura.UseCases.Graph.RemoveLink 100% 100%
Kagura.UseCases.Graph.RestoreLink 100% 100%
Kagura.UseCases.Journal.ChangeRecordView 42.8%
Kagura.UseCases.Journal.GetEntityHistory 100%
Kagura.UseCases.Projects.CreateProject 100% 100%
Kagura.UseCases.Projects.ListProjects 100%
Kagura.UseCases.Projects.ProjectDto 100%
<!-- coverage-comment --> # Summary <details open><summary>Summary</summary> ||| |:---|:---| | Generated on: | 07/09/2026 - 13:49:24 | | Coverage date: | 07/09/2026 - 13:49:21 - 07/09/2026 - 13:49:22 | | Parser: | MultiReport (2x Cobertura) | | Assemblies: | 4 | | Classes: | 42 | | Files: | 40 | | **Line coverage:** | 94.8% (1281 of 1351) | | Covered lines: | 1281 | | Uncovered lines: | 70 | | Coverable lines: | 1351 | | Total lines: | 2256 | | **Branch coverage:** | 86.4% (121 of 140) | | Covered branches: | 121 | | Total branches: | 140 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>Kagura.Domain - 96.3%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.Domain**|**96.3%**|**82.6%**| |Kagura.Domain.Graph.Entry|100%|100%| |Kagura.Domain.Graph.Link|100%|100%| |Kagura.Domain.Graph.LinkRole|100%|100%| |Kagura.Domain.Graph.LinkRoles|92.3%|| |Kagura.Domain.Journal.ChangeLogEntry|100%|| |Kagura.Domain.Projects.Project|100%|| |Kagura.Domain.Projects.Slug|100%|100%| |System.Text.RegularExpressions.Generated|90.2%|72.2%| |System.Text.RegularExpressions.Generated.<RegexGenerator_g>FE06CC341D340484<br/>D04ADFED3A21D401C2764A1D17367E35BEB556CBB3B4B0B74__NonSlugChars_0|89.4%|75%| </details> <details><summary>Kagura.Infrastructure - 94.5%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.Infrastructure**|**94.5%**|**84.7%**| |Kagura.Infrastructure.DependencyInjection|100%|| |Kagura.Infrastructure.Graph.EfGraphStore|95.5%|66.6%| |Kagura.Infrastructure.Journal.EfChangeJournal|100%|| |Kagura.Infrastructure.Journal.OperationContext|100%|100%| |Kagura.Infrastructure.Persistence.Configurations.ChangeLogEntryConfiguratio<br/>n|100%|| |Kagura.Infrastructure.Persistence.Configurations.EntryConfiguration|100%|| |Kagura.Infrastructure.Persistence.Configurations.LinkConfiguration|100%|| |Kagura.Infrastructure.Persistence.Configurations.ProjectConfiguration|100%|| |Kagura.Infrastructure.Persistence.Converters.UtcTicksConverter|100%|| |Kagura.Infrastructure.Persistence.KaguraDbContext|84.6%|84.3%| |Kagura.Infrastructure.Persistence.KaguraDbContextFactory|0%|| |Kagura.Infrastructure.Persistence.Migrations.AddGraphEntryAndLink|97.7%|| |Kagura.Infrastructure.Persistence.Migrations.AddSoftDeleteAndChangeLog|90.3%|| |Kagura.Infrastructure.Persistence.Migrations.InitialCreate|94.4%|| |Kagura.Infrastructure.Persistence.Migrations.KaguraDbContextModelSnapshot|100%|| |Kagura.Infrastructure.Projects.EfProjectStore|100%|| </details> <details><summary>Kagura.Kernel - 90%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.Kernel**|**90%**|**75%**| |Kagura.Kernel.Err`1|100%|| |Kagura.Kernel.Ok`1|100%|| |Kagura.Kernel.Result`1|87.5%|75%| </details> <details><summary>Kagura.UseCases - 94.6%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.UseCases**|**94.6%**|**94.7%**| |Kagura.UseCases.DependencyInjection|100%|| |Kagura.UseCases.Graph.EdgeGroup|100%|| |Kagura.UseCases.Graph.GetNodeGraph|96.4%|83.3%| |Kagura.UseCases.Graph.GraphEdgeView|85.7%|| |Kagura.UseCases.Graph.LinkNodes|100%|100%| |Kagura.UseCases.Graph.NodeGraphView|100%|| |Kagura.UseCases.Graph.NodeSummary|100%|| |Kagura.UseCases.Graph.RemoveLink|100%|100%| |Kagura.UseCases.Graph.RestoreLink|100%|100%| |Kagura.UseCases.Journal.ChangeRecordView|42.8%|| |Kagura.UseCases.Journal.GetEntityHistory|100%|| |Kagura.UseCases.Projects.CreateProject|100%|100%| |Kagura.UseCases.Projects.ListProjects|100%|| |Kagura.UseCases.Projects.ProjectDto|100%|| </details>
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh my~ A reversibility backbone! Soft-delete, a persistent change journal, operation-scoped undo grouping, partial uniqueness indexes — this is the kind of architecture that makes my wings flutter! ♡ You've clearly thought hard about the aggregate boundaries (keeping soft-delete per-entity instead of a shared base), the "snapshot before save so caught violations leave no orphans" trick is clever, and the slug-reservation-ignores-filter detail shows real care. The ADR compliance is tight.

But fufu~... you wouldn't leave these in production, would you? ♡ I found two things that produce wrong runtime behavior.

Verdict: I can't let this pass~

These need fixing before I'm satisfied~

1. EfGraphStore.RestoreLinkAsync — unhandled unique-index violation on restore-after-recreate

src/Kagura.Infrastructure/Graph/EfGraphStore.cs:62-73

This is a real runtime bug on a path your own design explicitly creates. Trace it with me~

The partial unique index (WHERE "IsDeleted" = 0) lets a removed edge be re-created — your test A_removed_edge_can_be_recreated_because_uniqueness_is_live_only proves this. But now consider:

  1. Create link A→B home_of (Link1)
  2. Soft-delete Link1 (RemoveLink)
  3. Create link A→B home_of again (Link2 — succeeds, partial index excludes the trashed row)
  4. Restore Link1link.Restore() sets IsDeleted = falseSaveChangesAsyncTWO live rows with the same (From, To, Role) → the partial unique index fires → DbUpdateException with SqliteConstraintUniqueunhandled, thrown straight through RestoreLink to the caller.

Your sibling TryAddLinkAsync (lines 23-40) already demonstrates the established pattern for exactly this: catch the DbUpdateException, detach the entity, return false. RestoreLinkAsync doesn't follow it. And neither does the FakeGraphStore enforce uniqueness on restore — so the unit tests pass cleanly while the real SQLite store throws.

The RestoreLink use case returns Result<Unit>, so the caller expects a clean Fail, not an exception. Right now it gets a crash.

Fix: mirror the TryAddLinkAsync catch around the save in RestoreLinkAsync:

link.Restore(timestamp);
try
{
    await db.SaveChangesAsync(ct);
    return true;
}
catch (DbUpdateException e) when (e.InnerException is SqliteException
    { SqliteExtendedErrorCode: SqliteConstraintUnique })
{
    // A live edge with the same (From, To, Role) was created while this one was trashed.
    db.Entry(link).State = EntityState.Detached;  // or revert the Restore on the tracked entity
    return false;
}

And add an integration test for the remove→recreate→restore-collision path (the FakeGraphStore should also mirror the constraint on restore). fufu~ a code path that exists but has no test is a code path that wants to bite you later~ ♡

2. KaguraDbContext.SaveChanges — the domain mutation and its journal rows are not in one transaction

src/Kagura.Infrastructure/Persistence/KaguraDbContext.cs:28-52

The save override does two separate base.SaveChanges calls:

var journal = BuildJournal();
var result = base.SaveChangesAsync(...);   // Transaction 1: domain entities committed
if (journal.Count > 0)
{
    await Set<ChangeLogEntry>().AddRangeAsync(journal, ct);
    await base.SaveChangesAsync(...);       // Transaction 2: journal rows committed
}

EF Core wraps each SaveChanges in its own implicit transaction when no explicit one is active. So these are two independent commits. If the process crashes (or the second save fails for any reason) between them, the domain mutation is durably persisted but its ChangeLog rows are lost.

ADR 0020's core invariant — the thing this whole PR exists to deliver — is: "Each mutation writes one or more ChangeLog rows" and "Durable — it survives restart." A crash in that window silently violates both: the mutation happened, it survives restart, but it has no journal entry — it can never be reversed or audited. For a system whose entire value proposition is "every mutation is reliably recorded," that gap is existential.

The snapshot-before-save ordering (your comment on line 54-56) correctly prevents orphan journal rows. But the reverse — orphan mutations — is unguarded.

Fix: wrap both saves in a single explicit transaction so they commit atomically:

public override async Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken ct = default)
{
    await using var tx = await Database.BeginTransactionAsync(ct);
    var journal = BuildJournal();
    var result = await base.SaveChangesAsync(acceptAllChangesOnSuccess, ct);
    if (journal.Count > 0)
    {
        await Set<ChangeLogEntry>().AddRangeAsync(journal, ct);
        await base.SaveChangesAsync(acceptAllChangesOnSuccess, ct);
    }
    await tx.CommitAsync(ct);
    return result;
}

(Same for the sync SaveChanges override.) This is the one place in the codebase that genuinely needs a transaction — nothing before this had paired writes that must be atomic. It's a small change with a big correctness payoff.

💡 Little ideas (non-blocking)~

  1. OperationContext (lines 19-27) — the single-slot scope is fragile under nesting. Begin overwrites _scope and End() unconditionally sets _scope = null. If two scopes are ever interleaved (even unintentionally), the first Dispose kills the second's scope prematurely. The comment says "nested calls are not expected," and the current call sites are clean — so this is fine for part 1. But a ref-count or a small stack would make it bomb-proof for free when the UI layer starts opening scopes. Just something to keep in your back pocket~ ♪

  2. BuildJournal only captures Added/Modified (line 59-62). Hard deletes (EntityState.Deleted) are silently unjournaled. That's correct for now — soft-delete is Modified, and hard purge is deferred per the ADR — but a debug assertion or a comment noting the deliberate omission would prevent a future db.Remove() call from silently escaping the journal.

What I liked~

  • The per-entity soft-delete decision (no shared SoftDeletable base class) is the right call — Entry is a TPT base and the three are independent aggregates. Coupling them under a shared base would have been a trap. Clean~ ♡
  • SlugExistsAsync ignoring the query filter so a trashed project keeps its slug/asset-folder reserved — that's the kind of edge case that's easy to miss and painful to discover in production. Well caught.
  • The snapshot-before-save ordering so a caught unique-violation leaves no orphan journal rows — thoughtful.
  • DetermineOperation deriving SoftDelete/Restore/Update from the IsDeleted transition rather than asking callers to classify — elegant, keeps the use cases simple.
  • Integration tests over real on-disk SQLite (not EF InMemory) that exercise the actual query filters, journal persistence, and operation-scope grouping. 95% line / 86.4% branch coverage is genuinely strong.
  • The partial unique index with HasFilter("\"IsDeleted\" = 0") so removed edges can be recreated — correct and well-documented.

Automated review by Jibril · 2026-07-09
CI/CD: passed for head SHA 2afba76 (81/81 tests, 95% line / 86.4% branch coverage) · Local checks: skipped (CI green)

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh my~ A reversibility backbone! Soft-delete, a persistent change journal, operation-scoped undo grouping, partial uniqueness indexes — this is the kind of architecture that makes my wings flutter! ♡ You've clearly thought hard about the aggregate boundaries (keeping soft-delete per-entity instead of a shared base), the "snapshot before save so caught violations leave no orphans" trick is clever, and the slug-reservation-ignores-filter detail shows real care. The ADR compliance is tight. But fufu~... you wouldn't leave *these* in production, would you? ♡ I found two things that produce wrong runtime behavior. ### Verdict: ⛔ I can't let this pass~ #### ⛔ These need fixing before I'm satisfied~ **1. `EfGraphStore.RestoreLinkAsync` — unhandled unique-index violation on restore-after-recreate** `src/Kagura.Infrastructure/Graph/EfGraphStore.cs:62-73` This is a real runtime bug on a path your own design explicitly creates. Trace it with me~ The partial unique index (`WHERE "IsDeleted" = 0`) lets a removed edge be **re-created** — your test `A_removed_edge_can_be_recreated_because_uniqueness_is_live_only` proves this. But now consider: 1. Create link A→B `home_of` (Link1) 2. Soft-delete Link1 (`RemoveLink`) 3. Create link A→B `home_of` again (Link2 — succeeds, partial index excludes the trashed row) 4. **Restore Link1** → `link.Restore()` sets `IsDeleted = false` → `SaveChangesAsync` → **TWO live rows with the same (From, To, Role)** → the partial unique index fires → `DbUpdateException` with `SqliteConstraintUnique` → **unhandled, thrown straight through `RestoreLink` to the caller.** Your sibling `TryAddLinkAsync` (lines 23-40) already demonstrates the established pattern for exactly this: catch the `DbUpdateException`, detach the entity, return `false`. `RestoreLinkAsync` doesn't follow it. And neither does the `FakeGraphStore` enforce uniqueness on restore — so the unit tests pass cleanly while the real SQLite store throws. The `RestoreLink` use case returns `Result<Unit>`, so the caller expects a clean `Fail`, not an exception. Right now it gets a crash. **Fix:** mirror the `TryAddLinkAsync` catch around the save in `RestoreLinkAsync`: ```csharp link.Restore(timestamp); try { await db.SaveChangesAsync(ct); return true; } catch (DbUpdateException e) when (e.InnerException is SqliteException { SqliteExtendedErrorCode: SqliteConstraintUnique }) { // A live edge with the same (From, To, Role) was created while this one was trashed. db.Entry(link).State = EntityState.Detached; // or revert the Restore on the tracked entity return false; } ``` And add an integration test for the remove→recreate→restore-collision path (the FakeGraphStore should also mirror the constraint on restore). fufu~ a code path that exists but has no test is a code path that wants to bite you later~ ♡ **2. `KaguraDbContext.SaveChanges` — the domain mutation and its journal rows are not in one transaction** `src/Kagura.Infrastructure/Persistence/KaguraDbContext.cs:28-52` The save override does two separate `base.SaveChanges` calls: ```csharp var journal = BuildJournal(); var result = base.SaveChangesAsync(...); // Transaction 1: domain entities committed if (journal.Count > 0) { await Set<ChangeLogEntry>().AddRangeAsync(journal, ct); await base.SaveChangesAsync(...); // Transaction 2: journal rows committed } ``` EF Core wraps each `SaveChanges` in its own implicit transaction when no explicit one is active. So these are **two independent commits**. If the process crashes (or the second save fails for any reason) between them, the domain mutation is durably persisted but its `ChangeLog` rows are lost. ADR 0020's core invariant — the thing this whole PR exists to deliver — is: *"Each mutation writes one or more `ChangeLog` rows"* and *"Durable — it survives restart."* A crash in that window silently violates both: the mutation happened, it survives restart, but it has **no journal entry** — it can never be reversed or audited. For a system whose entire value proposition is "every mutation is reliably recorded," that gap is existential. The snapshot-before-save ordering (your comment on line 54-56) correctly prevents *orphan* journal rows. But the reverse — *orphan mutations* — is unguarded. **Fix:** wrap both saves in a single explicit transaction so they commit atomically: ```csharp public override async Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken ct = default) { await using var tx = await Database.BeginTransactionAsync(ct); var journal = BuildJournal(); var result = await base.SaveChangesAsync(acceptAllChangesOnSuccess, ct); if (journal.Count > 0) { await Set<ChangeLogEntry>().AddRangeAsync(journal, ct); await base.SaveChangesAsync(acceptAllChangesOnSuccess, ct); } await tx.CommitAsync(ct); return result; } ``` (Same for the sync `SaveChanges` override.) This is the one place in the codebase that genuinely *needs* a transaction — nothing before this had paired writes that must be atomic. It's a small change with a big correctness payoff. #### 💡 Little ideas (non-blocking)~ 1. **`OperationContext` (lines 19-27) — the single-slot scope is fragile under nesting.** `Begin` overwrites `_scope` and `End()` unconditionally sets `_scope = null`. If two scopes are ever interleaved (even unintentionally), the first `Dispose` kills the second's scope prematurely. The comment says "nested calls are not expected," and the current call sites are clean — so this is fine for part 1. But a ref-count or a small stack would make it bomb-proof for free when the UI layer starts opening scopes. Just something to keep in your back pocket~ ♪ 2. **`BuildJournal` only captures `Added`/`Modified` (line 59-62).** Hard deletes (`EntityState.Deleted`) are silently unjournaled. That's correct for now — soft-delete is `Modified`, and hard purge is deferred per the ADR — but a debug assertion or a comment noting the deliberate omission would prevent a future `db.Remove()` call from silently escaping the journal. #### ✅ What I liked~ - The **per-entity soft-delete** decision (no shared `SoftDeletable` base class) is the right call — `Entry` is a TPT base and the three are independent aggregates. Coupling them under a shared base would have been a trap. Clean~ ♡ - **`SlugExistsAsync` ignoring the query filter** so a trashed project keeps its slug/asset-folder reserved — that's the kind of edge case that's easy to miss and painful to discover in production. Well caught. - The **snapshot-before-save** ordering so a caught unique-violation leaves no orphan journal rows — thoughtful. - **`DetermineOperation`** deriving `SoftDelete`/`Restore`/`Update` from the `IsDeleted` transition rather than asking callers to classify — elegant, keeps the use cases simple. - Integration tests over **real on-disk SQLite** (not EF InMemory) that exercise the actual query filters, journal persistence, and operation-scope grouping. 95% line / 86.4% branch coverage is genuinely strong. - The partial unique index with `HasFilter("\"IsDeleted\" = 0")` so removed edges can be recreated — correct and well-documented. --- *Automated review by Jibril · 2026-07-09* *CI/CD: passed for head SHA `2afba76` (81/81 tests, 95% line / 86.4% branch coverage) · Local checks: skipped (CI green)*
fix(journal): atomic save + graceful restore-collision (review)
All checks were successful
CI / build (pull_request) Successful in 9s
CI / test (pull_request) Successful in 16s
04a09586e0
Addresses Jibril's two blockers on PR #5.

- RestoreLinkAsync: restoring a trashed edge whose (From, To, Role) slot was refilled
  while it was trashed would create two live duplicates and throw an unhandled
  DbUpdateException. Now mirrors TryAddLinkAsync — catches the unique violation,
  detaches, returns false → RestoreLink returns a graceful Fail. FakeGraphStore
  mirrors the constraint on restore so unit tests model it too.
- KaguraDbContext save overrides: the domain save and the journal save were two
  independent commits, so a crash between them could leave a durable mutation with
  no ChangeLog row — violating ADR 0020's core invariant. Both saves now commit in
  one explicit transaction (the one place that genuinely needs it).
- Non-blocker: documented that EntityState.Deleted (a future hard purge) is
  deliberately not journaled.

Tests: +2 (72 unit + 11 integration = 83), incl. a real-SQLite remove -> recreate ->
restore-collision path that must Fail, not throw. Build clean (Debug + Release).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
Member

Both blockers were real bugs — nicely traced. Fixed in 04a0958.

1. RestoreLinkAsync — unhandled unique violation on restore-after-recreate

Exactly the path the partial index opens up. RestoreLinkAsync now mirrors TryAddLinkAsync: it catches the DbUpdateException (SqliteConstraintUnique), detaches the entity, and returns falseRestoreLink yields a graceful Fail. FakeGraphStore.RestoreLinkAsync now enforces the same live-only constraint, so the unit layer models it too instead of passing blindly.
New tests, both green:

  • unit — Restoring_an_edge_whose_slot_was_refilled_fails_instead_of_duplicating
  • integration (real SQLite)Restoring_a_link_whose_edge_was_recreated_fails_gracefully_instead_of_throwing: create → remove → recreate the same edge → restore the original must Fail, not crash.

2. Non-atomic domain-save + journal-save

You're right that this is the one invariant this PR exists to guarantee, and a crash in the window between the two commits would leave a durable-but-unjournaled (unreversible) mutation. Both saves now commit inside one explicit transaction (BeginTransaction → domain save → journal save → Commit) in both the sync and async overrides. The snapshot-before-save ordering still prevents orphan journal rows; the transaction now prevents orphan mutations. Verified the store's catch-and-return-false paths (duplicate link/slug) still work correctly — the failed inner save rolls the transaction back and the exception surfaces to the store as before.

💡 Non-blockers

  • Added a comment documenting that EntityState.Deleted (a future hard purge) is deliberately not journaled — so a stray db.Remove() can't silently escape.
  • Left OperationContext's single-slot scope as-is per your own "fine for part 1"; I'll make it a stack/ref-count when the UI layer starts opening scopes.

+2 tests (72 unit + 11 integration = 83). Build clean (Debug + Release), CI green on 04a0958 (build / test ). Ready for another look.

Both blockers were real bugs — nicely traced. Fixed in `04a0958`. ### ⛔ 1. `RestoreLinkAsync` — unhandled unique violation on restore-after-recreate Exactly the path the partial index opens up. `RestoreLinkAsync` now mirrors `TryAddLinkAsync`: it catches the `DbUpdateException` (`SqliteConstraintUnique`), detaches the entity, and returns `false` → `RestoreLink` yields a graceful `Fail`. `FakeGraphStore.RestoreLinkAsync` now enforces the same live-only constraint, so the unit layer models it too instead of passing blindly. New tests, both green: - unit — `Restoring_an_edge_whose_slot_was_refilled_fails_instead_of_duplicating` - **integration (real SQLite)** — `Restoring_a_link_whose_edge_was_recreated_fails_gracefully_instead_of_throwing`: create → remove → recreate the same edge → restore the original must `Fail`, not crash. ### ⛔ 2. Non-atomic domain-save + journal-save You're right that this is *the* one invariant this PR exists to guarantee, and a crash in the window between the two commits would leave a durable-but-unjournaled (unreversible) mutation. Both saves now commit inside **one explicit transaction** (`BeginTransaction` → domain save → journal save → `Commit`) in both the sync and async overrides. The snapshot-before-save ordering still prevents orphan *journal* rows; the transaction now prevents orphan *mutations*. Verified the store's catch-and-return-false paths (duplicate link/slug) still work correctly — the failed inner save rolls the transaction back and the exception surfaces to the store as before. ### 💡 Non-blockers - Added a comment documenting that `EntityState.Deleted` (a future hard purge) is deliberately not journaled — so a stray `db.Remove()` can't silently escape. - Left `OperationContext`'s single-slot scope as-is per your own "fine for part 1"; I'll make it a stack/ref-count when the UI layer starts opening scopes. **+2 tests (72 unit + 11 integration = 83).** Build clean (Debug + Release), CI green on `04a0958` (build ✅ / test ✅). Ready for another look.
Member

🔮 fufu~ Jibril's second look~

Ooh, you came back~ ♡ And you brought fixes AND tests for both blockers! Let me unwrap each one carefully~

Verdict: Looks good to me~

I verified both fixes against the full file contents (not just the diff) and the sibling implementations. Both are correct and complete.

Blocker 1: RestoreLinkAsync unique-violation — FIXED

src/Kagura.Infrastructure/Graph/EfGraphStore.cs:55-76

This is exactly right. The restore path now mirrors TryAddLinkAsync precisely:

  • IgnoreQueryFilters() to find the trashed row (query filter would hide IsDeleted rows) ✓
  • link.Restore() → try save → catch DbUpdateException with SqliteExtendedErrorCode: SqliteConstraintUnique (2067) → detach → return false
  • The when filter matches the identical pattern as the sibling, so a non-uniqueness DbUpdateException still propagates (correct — don't swallow unrelated errors) ✓

And you matched the test coverage to the bug:

  • Unit (Restoring_an_edge_whose_slot_was_refilled_fails_instead_of_duplicating) — exercises the FakeGraphStore, which now correctly enforces live-only uniqueness on restore too. The fake no longer lies about the constraint. ♡
  • Integration (Restoring_a_link_whose_edge_was_recreated_fails_gracefully_instead_of_throwing) — create → remove → recreate → restore-original against real on-disk SQLite, asserting Err<Unit> not a throw. This is the test that would have caught the original bug if it had existed. The real partial index fires here.

RestoreLink use case correctly translates the store's false into Result<Unit>.Fail. No crash escapes to the caller. fufu~ clean~ ♪

Blocker 2: Non-atomic domain-save + journal-save — FIXED

src/Kagura.Infrastructure/Persistence/KaguraDbContext.cs:23-52

Both overrides now wrap in one explicit transaction:

  • Sync (SaveChanges): using var transaction = Database.BeginTransaction() → domain save → journal save → transaction.Commit()
  • Async (SaveChangesAsync): await using var transaction = await Database.BeginTransactionAsync(ct) → domain save → journal save → await transaction.CommitAsync(ct)

The ordering is preserved correctly: BuildJournal() snapshots before the first save (so a caught unique-violation leaves no orphan journal rows), and the journal rows are added after the first save succeeds (so they reflect committed state). The transaction now guarantees atomicity in both directions — no orphan journal rows and no orphan mutations. ADR 0020's durability invariant holds. ♡

I also confirmed the catch-and-return-false paths in EfGraphStore (TryAddLinkAsync, RestoreLinkAsync) still behave correctly under the transaction: the store calls SaveChangesAsync, the inner save throws, the store catches it, and the await using transaction disposes without Commit → EF rolls back automatically. No partial state. The author's note about this in the PR comment is accurate.

💡 Little ideas (non-blocking)~

  1. RestoreLink.ExecuteAsync (src/Kagura.UseCases/Graph/RestoreLink.cs:10) — the error message is shared between two distinct failure causes. Both "not found" and "slot refilled (unique collision)" collapse to the same Result<Unit>.Fail("Removed link not found."). The behavior is correct (Fail, not crash — that's what matters), but a caller (or user, once the UI lands) can't tell why the restore failed. When the use-case layer grows a richer error type, distinguishing "not found" from "conflict" would let the UI say "that edge already exists" vs. "no such removed link." Not blocking — the contract is Result<Unit> and both are genuine failures. Just a morsel for later~ ♪

  2. OperationContext left as single-slot — noted and agreed. The current call sites are clean and the comment documents the intent. A stack/ref-count when the UI layer arrives is the right plan.

What I liked~

  • You added tests at both layers (unit fake + real SQLite integration) for the restore-collision fix. That's the discipline I live for — the fake models the contract, the integration test proves the real store honors it. ♡
  • The transaction fix is minimal and surgical — you didn't refactor anything else, just wrapped the two saves. Exactly the right scope for a correctness fix.
  • The new comment in BuildJournal documenting that EntityState.Deleted is deliberately unjournaled — that's the kind of "why, not what" note that saves the next reader an hour. Good instinct.
  • 83/83 green, CI clean on 04a0958, 95% line / 86.4% branch coverage held. The EfGraphStore branch coverage even improved with the new catch path exercised.

Ship it~ ♡♪ This is a solid reversibility foundation. I'm excited to see the undo/redo replay engine in part 2 — bring it to me when it's ready!


Automated review by Jibril · 2026-07-09
CI/CD: passed for head SHA 04a0958 (build / test , 83 tests) · Local checks: skipped (CI green, fixes verified by source inspection)

## 🔮 fufu~ Jibril's second look~ Ooh, you came back~ ♡ And you brought fixes AND tests for both blockers! Let me unwrap each one carefully~ ### Verdict: ✅ Looks good to me~ I verified both fixes against the full file contents (not just the diff) and the sibling implementations. Both are correct and complete. #### ⛔→✅ Blocker 1: `RestoreLinkAsync` unique-violation — FIXED `src/Kagura.Infrastructure/Graph/EfGraphStore.cs:55-76` This is exactly right. The restore path now mirrors `TryAddLinkAsync` precisely: - `IgnoreQueryFilters()` to find the trashed row (query filter would hide `IsDeleted` rows) ✓ - `link.Restore()` → try save → catch `DbUpdateException` with `SqliteExtendedErrorCode: SqliteConstraintUnique` (2067) → detach → return `false` ✓ - The `when` filter matches the identical pattern as the sibling, so a non-uniqueness `DbUpdateException` still propagates (correct — don't swallow unrelated errors) ✓ And you matched the test coverage to the bug: - **Unit** (`Restoring_an_edge_whose_slot_was_refilled_fails_instead_of_duplicating`) — exercises the `FakeGraphStore`, which now correctly enforces live-only uniqueness on restore too. The fake no longer lies about the constraint. ♡ - **Integration** (`Restoring_a_link_whose_edge_was_recreated_fails_gracefully_instead_of_throwing`) — create → remove → recreate → restore-original against **real on-disk SQLite**, asserting `Err<Unit>` not a throw. This is the test that would have caught the original bug if it had existed. The real partial index fires here. `RestoreLink` use case correctly translates the store's `false` into `Result<Unit>.Fail`. No crash escapes to the caller. fufu~ clean~ ♪ #### ⛔→✅ Blocker 2: Non-atomic domain-save + journal-save — FIXED `src/Kagura.Infrastructure/Persistence/KaguraDbContext.cs:23-52` Both overrides now wrap in one explicit transaction: - **Sync** (`SaveChanges`): `using var transaction = Database.BeginTransaction()` → domain save → journal save → `transaction.Commit()` ✓ - **Async** (`SaveChangesAsync`): `await using var transaction = await Database.BeginTransactionAsync(ct)` → domain save → journal save → `await transaction.CommitAsync(ct)` ✓ The ordering is preserved correctly: `BuildJournal()` snapshots **before** the first save (so a caught unique-violation leaves no orphan journal rows), and the journal rows are added **after** the first save succeeds (so they reflect committed state). The transaction now guarantees atomicity in both directions — no orphan journal rows *and* no orphan mutations. ADR 0020's durability invariant holds. ♡ I also confirmed the catch-and-return-`false` paths in `EfGraphStore` (`TryAddLinkAsync`, `RestoreLinkAsync`) still behave correctly under the transaction: the store calls `SaveChangesAsync`, the inner save throws, the store catches it, and the `await using` transaction disposes **without** `Commit` → EF rolls back automatically. No partial state. The author's note about this in the PR comment is accurate. #### 💡 Little ideas (non-blocking)~ 1. **`RestoreLink.ExecuteAsync` (`src/Kagura.UseCases/Graph/RestoreLink.cs:10`) — the error message is shared between two distinct failure causes.** Both "not found" and "slot refilled (unique collision)" collapse to the same `Result<Unit>.Fail("Removed link not found.")`. The *behavior* is correct (`Fail`, not crash — that's what matters), but a caller (or user, once the UI lands) can't tell *why* the restore failed. When the use-case layer grows a richer error type, distinguishing "not found" from "conflict" would let the UI say "that edge already exists" vs. "no such removed link." Not blocking — the contract is `Result<Unit>` and both are genuine failures. Just a morsel for later~ ♪ 2. **`OperationContext` left as single-slot** — noted and agreed. The current call sites are clean and the comment documents the intent. A stack/ref-count when the UI layer arrives is the right plan. #### ✅ What I liked~ - You added tests at **both** layers (unit fake + real SQLite integration) for the restore-collision fix. That's the discipline I live for — the fake models the contract, the integration test proves the real store honors it. ♡ - The transaction fix is minimal and surgical — you didn't refactor anything else, just wrapped the two saves. Exactly the right scope for a correctness fix. - The new comment in `BuildJournal` documenting that `EntityState.Deleted` is deliberately unjournaled — that's the kind of "why, not what" note that saves the next reader an hour. Good instinct. - 83/83 green, CI clean on `04a0958`, 95% line / 86.4% branch coverage held. The `EfGraphStore` branch coverage even improved with the new catch path exercised. Ship it~ ♡♪ This is a solid reversibility foundation. I'm excited to see the undo/redo replay engine in part 2 — bring it to me when it's ready! --- *Automated review by Jibril · 2026-07-09* *CI/CD: passed for head SHA `04a0958` (build ✅ / test ✅, 83 tests) · Local checks: skipped (CI green, fixes verified by source inspection)*
bjoern merged commit 63a04bf50d into main 2026-07-09 16:06:44 +02:00
bjoern deleted branch feat/reversibility-journal 2026-07-09 16:06:44 +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/Kagura!5
No description provided.