fix(documents): tag characters leave file names, refusals get their own type, an unchanged category saves nothing — and the reader's survivors pinned #43
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "fix/documents-domain-gaps"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
This PR closes four gaps in the documents domain, all in one commit.
ImageFiles.NameOfused to filter bychar. A format character beyond the BMP is stored as two surrogate chars, and neither half reads as Format on its own. That let through the invisible tag characters (U+E0000–U+E007F), which can spell out hidden text, as well as U+1D173 and others like it. The filter now reads by code point (EnumerateRunes,Rune.GetUnicodeCategory) and strips them. That is what ADR 0029 already requires ("without control or format characters"). A lone surrogate becomes U+FFFD. The 255-character cut is unchanged and still never splits a surrogate pair.WriteRefusedException, inTsumugi.UseCases.Documents.EfDocumentStore,DocumentWritesand the adapter tests'FakeDocumentStorenow throw it for every expected refusal. Six document use cases write through the store:CreateDocument,UpdateDocument,MoveDocuments,SetDocumentImage,DeleteCategoryandChangeCategoryFields. They now catch only this type and turn it into a failed result. Before, they caught everyInvalidOperationException. So an IOE from EF Core or the context (a nestedWriteAsync, a disposed context) is treated as a bug again. It reaches Kagaku.UI.Fluxor's contained error modal (CircuitErrorContainmentTests), and its text is no longer shown to the writer as a refusal. The contract comments onIDocumentStoreandDocumentWritesnow say this.DocumentCategory.Renameis replaced byRevise(name, description, timestamp).Revisereturns whether anything changed and stampsUpdatedAtonly in that case, likeRedefineandDocument.Revise.ApplyCategoryAsyncsaves only whenRevisereturns true. CallingUpdateCategorywith the stored profile therefore no longer leaves an undo step that undoes nothing.CategoryEditor.Matchesalready blocked this in single-tab use; it could still happen through a two-tab race or a direct use-case call.Describestays, because the two creation paths use it.InferUnit'sUnitProblemguard: a suffix that is too long, or that spans two lines, is never inferred as a unit. A 16-character suffix is.NumberToken: -, U+2212 and +.InTitleOrder'sThenBy(Id): two documents with the same title seed in id order.NumberText's 15-digit limit.Document.Revisetrims the summary.ReorderCategoriesAsyncstamps and journals only the categories that move.Heads-up for the parallel arcs:
DocumentCategory.Renameis nowRevise(name, description, ts), which returns abool. Document refusals are nowWriteRefusedException, notInvalidOperationException. After this merges, update any new test that callscategory.Rename, or that assertsThrowsAsync<InvalidOperationException>against the document store or a hand-writtenIDocumentStorefake. I checked fix/journal-undo-floor: its "rename" inImageFieldTestsis a fieldEditField, so it does not conflict.Docs: none. No ADR states the old IOE convention or a rule for journaling no-ops, and the code now does what ADR 0029's name rule says. No migration. No new entity, node, tool or field kind.
ApplyCategoryAsyncstill runs insideWriteAsync.Verification
Suites. All affected suites are green on the pushed head: Domain 481/481, UseCases 167/167, BlazorAdapter 355/355, Integration 376/376.
Flake checks. No failures in any run:
DocumentUseCaseTests,EfDocumentStoreTests,FieldStoreTests,ImageFieldTests, 30 tests) andDocumentRefusalTests8 times.DocumentRefusalTestsandImageFilesTests5 more times.New tests:
ImageFilesTests: the tag block, U+1D173, a letter or emoji written as a surrogate pair staying, and a lone surrogate becoming U+FFFD.DocumentCategoryTests.Revise_applies_the_profile_trimmed_and_stamps_only_a_change.DocumentUseCaseTests:A_category_save_that_changes_nothing_writes_no_journal_rowandReordering_writes_only_the_categories_that_move.DocumentRefusalTests(UseCases): for each of the six use cases, a refusal comes back asErrwith the store's own message, and an IOE or anObjectDisposedExceptionescapes unchanged.FieldStoreTests(9) andEfDocumentStoreTests(5).FieldsPlannerTests,FieldReadingTestsandDocumentReviseTests.Mutation probes. The implementer and an independent reviewer ran about 100 compiling mutants between them. The reviewer's 100 added a mutant for every else-arm and exact value:
ThenByDescendingTrimEnd/TrimStarton the summary</>in the reorder guardReviseEach mutant was killed by its own test. That includes all 12 reachable throw sites reverted to IOE, and all six catches widened to
Exception, narrowed to IOE, or widened to "WriteRefused or IOE". Three survived, and none of them is a gap:ApplyCategoryAsyncalways saving is an equivalent mutant. WhenRevisereturns false, nothing is Modified: the journal is empty, no notifications are built, andInProcessDomainChangedBus.Publishreturns early on the empty list.ReviseDocumentAsync's "category no longer exists" throw to IOE survives because that throw is unreachable.Document→DocumentCategoryhas a cascade FK, and the document is loaded under the IMMEDIATE write lock, so its category always exists.Independent review. Two reviewers looked at the branch: one read-only, one who ran the mutation probes. Their verdicts were "ready" and "fix-then-ready". Their findings:
DocumentRefusalTestsused "The target category no longer exists." as its refusal message.DeleteCategoryand the store produce that exact string. So a mutant that returned that constant instead of the refusal's message survived the UseCases suite for all six use cases; only the Integration and adapter suites killed it. The test now injects a message per use case ("The store refused {useCase}.") that nothing else produces. Re-probed:DocumentRefusalTestsalone now kills the constant-message mutant for all six use cases.StringBuilder.Append(Rune)overload, so the call bound toAppend(object).NameOfis back to the one-liner shape, now overEnumerateRunes(). The rune-filter probes were re-run on it (per-char filter, no format check, no control check, lone surrogate dropped, no trim), and all were killed.ImageFilesTestsnow writes the expected replacement characters as escape sequences, like the neighbouring cases, instead of literal glyphs.Revise's summary comment now reads "when neither changes". The unit test's comment now says "a suffix of 18 characters".DocumentsEffectsshould settle on a throw:OnUpdateDocumentAsync,OnUpdateCategoryAsyncandOnChangeCategoryFieldsAsync. They should do whatOnSetImageAsyncalready does: dispatchDocumentAutoSaveFailed, thenthrow;. Otherwise Saving… and the in-flight drafts can stay stuck after the modal is dismissed. ASqliteExceptionalready takes this path today.load_documentshould read the name throughImageFiles.NameOf, or a later migration should re-runNameOfoverStoryImages.FileName.Other follow-ups:
MoveSegmentsandEfSegmentStore(the segments track) still catch every IOE and should adoptWriteRefusedException; the type could then move to the UseCases root.Chapter.Renamestill stampsUpdatedAteven when nothing changes.DocumentCategory.Describecan go onceCreateStorypasses the description through the constructor.No browser pass: nothing in the UI changed.
🤖 Generated with Claude Code
Summary
Summary
Coverage
Kagaku.Flow - 99.7%
E95E47D27E8B105AC5589487C0997B6B65384AEDBF572144E__ColorToken_2
E95E47D27E8B105AC5589487C0997B6B65384AEDBF572144E__Socket_1
E95E47D27E8B105AC5589487C0997B6B65384AEDBF572144E__TypeId_0
Kagaku.Flow.OpenRouter - 100%
Kagaku.Flow.Testing - 100%
Tsumugi.BlazorAdapter - 95.6%
Tsumugi.Domain - 96.3%
BE2A118445F5EE9A5FB1F861C394AC933A547F31011ADF02F__DigitsAndSeparators_2
BE2A118445F5EE9A5FB1F861C394AC933A547F31011ADF02F__LineBreak_0
BE2A118445F5EE9A5FB1F861C394AC933A547F31011ADF02F__NumberToken_1
BE2A118445F5EE9A5FB1F861C394AC933A547F31011ADF02F__Plain_4
BE2A118445F5EE9A5FB1F861C394AC933A547F31011ADF02F__ThousandsGroup_3
Tsumugi.Infrastructure - 97.9%
A55E0373ED22DC58420DBB40F103A46B68DC4D9CE5A49E00B__Pattern_0
on
tion
n
Tsumugi.Kernel - 90.9%
Tsumugi.Server - 100%
Tsumugi.UseCases - 98.3%
🔮 fufu~ Jibril reviewed your code!
Oh? Oh! Four fixes in one commit, each with its refusal typed, its no-op measured, and its invisible ink scrubbed by code point~ ♡ I came sharpened for a hunt and found the trails already baited with mutation probes. How considerate of you~
Verdict: ✅ Looks good to me~
I read the full 22-file diff and the full contents of every changed production file, then verified the load-bearing chains myself:
EnumerateRunes+Rune.GetUnicodeCategorystrips the tag block (U+E0000–U+E007F) and U+1D173 where the old per-charfilter passed both halves of each pair as mere surrogates. My probe reverted it to the old filter: the hidden "ignore previous" text survived the wash — killed by exactly the 2 new tests, 32 neighbors green. Lone surrogates read U+FFFD;𝒜/😀pairs stay. And the 255-cut logic composes correctly with the new string (the high-surrogate look-back still guards the boundary).EfDocumentStore/DocumentWrites/FakeDocumentStorethrowWriteRefusedException; all six catches catch only it; zeroDocumentCategory.Renamecallers survive (Story/Chapter.Renameare other entities);MoveSegmentsstill catching IOE is correct sequencing, not an oversight — its store still throws IOE. The no-nesting IOE fromWriteAsyncnow escapes to the circuit's containment instead of masquerading as a refusal message. That is a bug becoming visible again, which is exactly what a bug should do~Revisereturning false mutates nothing → no Modified entry →BuildJournal/TrackedMutationssees nothing → no journal row, no undo step, no notification. I walkedTsumugiDbContext.WriteAsync→SaveChangesAsync→BuildJournalline by line; the chain holds. Probe: always-stamp killed at both layers (domain stamp pin + Integration journal-count pin).if category.Order != order) is pinned twice —UpdatedAtstaysT0for the category that keeps its place, and the journal carries only the movers. Probe killed both.InTitleOrder'sThenBy(d => d.Id)is real and now pinned.Local verification (CI absent for
1abeaff— no bot comment): Release build 0w/0e, all four suites green digit-exact vs your table — Domain 481, UseCases 167, Integration 376, Adapter 355. My five probes above, each restored pristine (byte-verified) and re-greened. The one survivor among them — the adapter fake's image-ownership throw reverted to IOE — is disclosed and I confirmed your framing: it's fake fidelity only;ImageFieldTestspins that refusal against the real store with three exact-message asserts. Also:DocumentRefusalTests' sentinel messages mean the constant-message mutant dies 6/6 inside the suite alone — a very tasty trap design~💡 Little ideas (non-blocking)~
OnUpdateDocumentAsync/OnUpdateCategoryAsync/OnChangeCategoryFieldsAsyncdispatch-then-swallow whereOnSetImageAsyncdispatches thenthrow;. The stuck-Saving… residue after a containment-modal dismiss is a pre-existing gap (SqliteExceptionalready takes that path), but this PR widens its reach — don't let it linger long, fufu~NameOfin Phase 4'sload_document(or a re-run migration) closes the retroactive half. Already noted in the body — pinning it here so it doesn't evaporate~ ♡✅ What I liked~
DocumentRefusalTestsis a beautiful little machine: one world, six use cases, sentinel messages no store could word by accident, and exact-type escape asserts (Assert.Same!) for both IOE andObjectDisposedException. The refusal/bug boundary has never been this explicit.Revisereplacing theRename+Describepair inside the write lock is a genuine dedup — and returningboolmirrorsRedefineandDocument.Reviseso exactly that the sibling pattern sings.ApplyCategoryAsyncequivalence myself through the context code — it holds.Merge it before I change my mind~ fufufu ♡
Automated review by Jibril · 2026-09-26
*CI/CD: absent for head SHA
1abeaff· Local checks: Release 0w/0e · 1379/1379 across 4 suites · 5 mutation probes (4 killed, 1 disclosed-and-verified)