feat: Phase 2 · 2/7 — settings core: encrypted key & per-agent models #15

Merged
bjoern merged 2 commits from feat/p2-settings-core into main 2026-08-13 06:23:57 +02:00
Member

Cut 2 of the Phase 2 plan (see #13): the settings substrate under the upcoming screen — no UI yet.

What's in

  • Domain
    • AppSetting — one global key/value row (ADR 0006, Kagura's shape). Secrets arrive already encrypted; the entity neither knows nor cares.
    • AgentKind — the roster as an explicitly numbered stored enum (per-agent model settings now, run execution rows later — ADR 0018). Deferred pixel-pass agents join with their milestone.
    • AgentRoster / AgentDescriptor — each agent's display name, vision requirement (the four annotation agents; research/bible/translation are free choice — ADR 0015), and default model, so a fresh install runs before settings is ever opened. Defaults: a strong model where judgment is the job, a cheaper one for the mechanical stages (refinement, transcription).
  • UseCases (Settings/)
    • IAppSettingsStore port — plain values round-trip; secrets are one-way from the UI's view (HasSecretAsync for the masked display, GetSecretAsync only for server-side flows). RemoveAsync backs "clear → default".
    • SaveOpenRouterKeyvalidates against OpenRouter first (via #13's gateway), only then stores, encrypted; Ok carries the account facts for the "saved" confirmation.
    • SaveAgentModel — the constraints are enforced here, not advised in the UI: model must exist in the catalog, must support tools (ADR 0014), and must be vision-capable for a vision-required agent. Blank clears the row → roster default (and never asks the provider).
    • ListModelOptions — the catalog under the stored key, narrowed to tool-capable models.
    • GetSettings — provider state (HasOpenRouterKey, never the key) + every agent with chosen/default/effective model.
    • SettingKeys — stored strings behind an explicit enum→key map, so a C# rename can never orphan a row.
  • Infrastructure
    • EfAppSettingsStore — DataProtection-encrypts secrets before they touch the row (purpose "Orihon.AppSettings"); DB file alone never leaks a credential, decrypting needs the keyring too (ADR 0006's documented backup consequence).
    • AppSettingConfiguration (unique Key index) + migration AddAppSettings.
    • Only Microsoft.AspNetCore.DataProtection.Abstractions referenced — the Server already hosts the concrete provider and persisted keyring from Phase 0.

Tests (+33, 275 total)

  • Domain: AppSetting guards/update; roster covers every AgentKind exactly once, vision flags match ADR 0015, defaults present, out-of-roster lookup throws.
  • UseCases (over a new strict FakeAppSettingsStore whose secret path enforces the cipher marker — a secret written through the plain path fails the test): blank/invalid key never stored, valid key trimmed+validated+encrypted; fresh-install defaults; choice overrides only its agent; picker without key / with failure / tool-filter; save enforces catalog membership, tool support, vision (and accepts text-only for free-choice agents); clear removes the row without a provider call; distinct setting keys. DI tripwire extended with the 4 new use cases + 2 new fakes.
  • Integration (real SQLite + a real ephemeral DataProtection provider): plain/secret round-trips across scopes, ciphertext at rest (raw row never contains the plaintext), single-row upsert, remove tolerates absence, unique key index at the DB level.

🤖 Generated with Claude Code

Cut 2 of the Phase 2 plan (see #13): the settings substrate under the upcoming screen — no UI yet. ## What's in - **Domain** - `AppSetting` — one global key/value row (ADR 0006, Kagura's shape). Secrets arrive already encrypted; the entity neither knows nor cares. - `AgentKind` — the roster as an explicitly numbered stored enum (per-agent model settings now, run execution rows later — ADR 0018). Deferred pixel-pass agents join with their milestone. - `AgentRoster` / `AgentDescriptor` — each agent's display name, **vision requirement** (the four annotation agents; research/bible/translation are free choice — ADR 0015), and **default model**, so a fresh install runs before settings is ever opened. Defaults: a strong model where judgment is the job, a cheaper one for the mechanical stages (refinement, transcription). - **UseCases** (`Settings/`) - `IAppSettingsStore` port — plain values round-trip; secrets are one-way from the UI's view (`HasSecretAsync` for the masked display, `GetSecretAsync` only for server-side flows). `RemoveAsync` backs "clear → default". - `SaveOpenRouterKey` — **validates against OpenRouter first** (via #13's gateway), only then stores, encrypted; Ok carries the account facts for the "saved" confirmation. - `SaveAgentModel` — the constraints are **enforced here, not advised in the UI**: model must exist in the catalog, must support tools (ADR 0014), and must be vision-capable for a vision-required agent. Blank clears the row → roster default (and never asks the provider). - `ListModelOptions` — the catalog under the stored key, narrowed to tool-capable models. - `GetSettings` — provider state (`HasOpenRouterKey`, never the key) + every agent with chosen/default/effective model. - `SettingKeys` — stored strings behind an explicit enum→key map, so a C# rename can never orphan a row. - **Infrastructure** - `EfAppSettingsStore` — DataProtection-encrypts secrets before they touch the row (purpose `"Orihon.AppSettings"`); DB file alone never leaks a credential, decrypting needs the keyring too (ADR 0006's documented backup consequence). - `AppSettingConfiguration` (unique `Key` index) + migration `AddAppSettings`. - Only `Microsoft.AspNetCore.DataProtection.Abstractions` referenced — the Server already hosts the concrete provider and persisted keyring from Phase 0. ## Tests (+33, 275 total) - **Domain**: `AppSetting` guards/update; roster covers every `AgentKind` exactly once, vision flags match ADR 0015, defaults present, out-of-roster lookup throws. - **UseCases** (over a new **strict `FakeAppSettingsStore`** whose secret path enforces the cipher marker — a secret written through the plain path fails the test): blank/invalid key never stored, valid key trimmed+validated+encrypted; fresh-install defaults; choice overrides only its agent; picker without key / with failure / tool-filter; save enforces catalog membership, tool support, vision (and accepts text-only for free-choice agents); clear removes the row without a provider call; distinct setting keys. DI tripwire extended with the 4 new use cases + 2 new fakes. - **Integration** (real SQLite + a real ephemeral DataProtection provider): plain/secret round-trips across scopes, **ciphertext at rest** (raw row never contains the plaintext), single-row upsert, remove tolerates absence, unique key index at the DB level. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat: add the settings core — encrypted key, per-agent models
All checks were successful
CI / build (pull_request) Successful in 22s
CI / test (pull_request) Successful in 32s
7eecf7f7f9
The settings substrate under the Phase 2 screen (ADR 0006, 0015),
mirroring Kagura's shape: an AppSetting key/value table whose secret
rows are DataProtection-encrypted by the store adapter, so the DB file
alone never leaks a usable credential.

The roster (ADR 0017) becomes code: AgentKind (explicitly numbered —
run rows will store it) and AgentRoster with each agent's vision
requirement and default model, so a fresh install runs before the
settings screen is ever opened. Four use cases carry the screen:
SaveOpenRouterKey validates against the provider before anything is
written; SaveAgentModel enforces the catalog, tool support, and the
vision constraint server-side — a text-only model physically cannot be
assigned to a pixel job; ListModelOptions narrows the catalog to
tool-capable models; GetSettings composes the screen's world.

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

Summary

Summary
Generated on: 07/24/2026 - 21:57:20
Coverage date: 07/24/2026 - 21:57:12 - 07/24/2026 - 21:57:16
Parser: MultiReport (4x Cobertura)
Assemblies: 6
Classes: 124
Files: 96
Line coverage: 96.1% (3930 of 4086)
Covered lines: 3930
Uncovered lines: 156
Coverable lines: 4086
Total lines: 7877
Branch coverage: 85.7% (655 of 764)
Covered branches: 655
Total branches: 764
Method coverage: Feature is only available for sponsors

Coverage

Orihon.BlazorAdapter - 80.7%
Name Line Branch
Orihon.BlazorAdapter 80.7% 76.4%
Orihon.BlazorAdapter.Bible.BiblePage 71.7% 59%
Orihon.BlazorAdapter.BlazorAdapterAssembly 100%
Orihon.BlazorAdapter.Debounce 91.6% 93.7%
Orihon.BlazorAdapter.PageWorkspace.PageWorkspacePage 84.6% 77.8%
Orihon.BlazorAdapter.Projects.ProjectListPage 86% 85.2%
Orihon.BlazorAdapter.Projects.ProjectWizardPage 91.5% 83.3%
Orihon.BlazorAdapter.Workspace.ProjectWorkspacePage 75.4% 82.8%
Orihon.Domain - 100%
Name Line Branch
Orihon.Domain 100% 100%
Orihon.Domain.Agents.AgentDescriptor 100%
Orihon.Domain.Agents.AgentRoster 100% 100%
Orihon.Domain.Bible.Character 100% 100%
Orihon.Domain.Bible.GlossaryEntry 100% 100%
Orihon.Domain.Bible.LoreEntry 100% 100%
Orihon.Domain.Bible.PageSummary 100%
Orihon.Domain.Bible.StoryBeat 100%
Orihon.Domain.Projects.Project 100% 100%
Orihon.Domain.Projects.ProjectProfile 100%
Orihon.Domain.Settings.AppSetting 100%
Orihon.Domain.Text 100% 100%
Orihon.Domain.Translation.BoundingBox 100%
Orihon.Domain.Translation.Chapter 100%
Orihon.Domain.Translation.Page 100%
Orihon.Domain.Translation.Region 100% 100%
Orihon.Domain.Translation.RegionProfile 100%
Orihon.Infrastructure - 99%
Name Line Branch
Orihon.Infrastructure 99% 97.5%
Orihon.Infrastructure.Bible.EfBibleStore 100% 100%
Orihon.Infrastructure.DependencyInjection 100%
Orihon.Infrastructure.Gateways.OpenRouterLlmGateway 100% 91.1%
Orihon.Infrastructure.Persistence.Configurations.AppSettingConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.ChapterConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.CharacterConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.GlossaryEntryConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.JsonColumnMapper 100%
Orihon.Infrastructure.Persistence.Configurations.LoreEntryConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.PageConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.PageSummaryConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.ProjectConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.RegionConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.StoryBeatConfiguration 100%
Orihon.Infrastructure.Persistence.Converters.UtcTicksConverter 100%
Orihon.Infrastructure.Persistence.Migrations.AddAppSettings 99.3%
Orihon.Infrastructure.Persistence.Migrations.InitialTranslationDomain 97.3%
Orihon.Infrastructure.Persistence.Migrations.OrihonDbContextModelSnapshot 100%
Orihon.Infrastructure.Persistence.OrihonDbContext 100%
Orihon.Infrastructure.Persistence.OrihonDbContextFactory 100%
Orihon.Infrastructure.Projects.EfProjectStore 100% 100%
Orihon.Infrastructure.Projects.FileSystemPageImageStore 100% 100%
Orihon.Infrastructure.Settings.EfAppSettingsStore 100% 100%
Orihon.Infrastructure.Translation.EfChapterStore 100% 100%
Orihon.Infrastructure.Translation.EfPageStore 100% 100%
Orihon.Infrastructure.Translation.EfRegionStore 100% 100%
Orihon.Infrastructure.Translation.Ordering 100% 100%
Orihon.Kernel - 90.9%
Name Line Branch
Orihon.Kernel 90.9% 75%
Orihon.Kernel.Err`1 100%
Orihon.Kernel.Ok`1 100%
Orihon.Kernel.Result`1 88.8% 75%
Orihon.Server - 91.3%
Name Line Branch
Orihon.Server 91.3% 64.7%
Orihon.Server.Components.App 100%
Orihon.Server.Components.Layout.MainLayout 100%
Orihon.Server.Components.Pages.Gate 64.2% 66.6%
Orihon.Server.Security.AccessGate 91.8% 41.6%
Orihon.Server.Security.AccessSecret 100% 50%
Program 94.5% 85.7%
Orihon.UseCases - 99.1%
Name Line Branch
Orihon.UseCases 99.1% 97.9%
Orihon.UseCases.Bible.AddCharacter 100% 100%
Orihon.UseCases.Bible.AddGlossaryEntry 100% 100%
Orihon.UseCases.Bible.AddLoreEntry 100% 100%
Orihon.UseCases.Bible.AddStoryBeat 100% 100%
Orihon.UseCases.Bible.BibleDto 100%
Orihon.UseCases.Bible.CharacterDto 100%
Orihon.UseCases.Bible.DeleteCharacter 100% 100%
Orihon.UseCases.Bible.DeleteGlossaryEntry 100% 100%
Orihon.UseCases.Bible.DeleteLoreEntry 100% 100%
Orihon.UseCases.Bible.DeletePageSummary 100% 100%
Orihon.UseCases.Bible.DeleteStoryBeat 100% 100%
Orihon.UseCases.Bible.GetBible 100% 100%
Orihon.UseCases.Bible.GlossaryEntryDto 100%
Orihon.UseCases.Bible.LoreEntryDto 100%
Orihon.UseCases.Bible.PageSummaryDto 100%
Orihon.UseCases.Bible.ReorderStoryBeats 100%
Orihon.UseCases.Bible.SetPageSummary 100% 100%
Orihon.UseCases.Bible.StoryBeatDto 100%
Orihon.UseCases.Bible.UpdateCharacter 100% 100%
Orihon.UseCases.Bible.UpdateGlossaryEntry 100% 100%
Orihon.UseCases.Bible.UpdateLoreEntry 100% 100%
Orihon.UseCases.Bible.UpdateStoryBeat 100% 100%
Orihon.UseCases.Chapters.ChapterDto 100%
Orihon.UseCases.Chapters.CreateChapter 100% 100%
Orihon.UseCases.Chapters.DeleteChapter 100% 100%
Orihon.UseCases.Chapters.RenameChapter 100% 100%
Orihon.UseCases.Chapters.ReorderChapters 100%
Orihon.UseCases.DependencyInjection 100%
Orihon.UseCases.Diagnostics.SeedDevData 99% 92.8%
Orihon.UseCases.Gateways.LlmKeyInfo 100%
Orihon.UseCases.Gateways.LlmModel 100%
Orihon.UseCases.NextOrder 100%
Orihon.UseCases.Pages.DeletePage 100% 100%
Orihon.UseCases.Pages.GetPage 100% 100%
Orihon.UseCases.Pages.GetProjectWorkspace 100% 100%
Orihon.UseCases.Pages.ImportPages 98.4% 96.4%
Orihon.UseCases.Pages.ImportPagesResult 100%
Orihon.UseCases.Pages.MovePage 100% 92.8%
Orihon.UseCases.Pages.PageDetailDto 100%
Orihon.UseCases.Pages.PageDto 100%
Orihon.UseCases.Pages.PageUpload 100%
Orihon.UseCases.Pages.ProjectWorkspaceDto 100%
Orihon.UseCases.Pages.ReorderPages 100%
Orihon.UseCases.Pages.SetPageMeta 100% 100%
Orihon.UseCases.Pages.WorkspaceChapterDto 100%
Orihon.UseCases.Projects.CompleteProjectSetup 100% 91.6%
Orihon.UseCases.Projects.CreateProject 100% 100%
Orihon.UseCases.Projects.DeleteProject 100% 100%
Orihon.UseCases.Projects.GetProject 100% 100%
Orihon.UseCases.Projects.ListProjects 100%
Orihon.UseCases.Projects.ProjectDto 95.8%
Orihon.UseCases.Projects.StoredPageImage 100%
Orihon.UseCases.Projects.UpdateProjectMetadata 100% 100%
Orihon.UseCases.Regions.CreateRegion 100% 100%
Orihon.UseCases.Regions.DeleteRegion 100% 100%
Orihon.UseCases.Regions.RegionDto 97%
Orihon.UseCases.Regions.ReorderRegions 100%
Orihon.UseCases.Regions.UpdateRegion 100% 100%
Orihon.UseCases.Settings.AgentSettingDto 71.4% 100%
Orihon.UseCases.Settings.GetSettings 100% 100%
Orihon.UseCases.Settings.ListModelOptions 100% 100%
Orihon.UseCases.Settings.SaveAgentModel 100% 100%
Orihon.UseCases.Settings.SaveOpenRouterKey 100% 100%
Orihon.UseCases.Settings.SettingKeys 100% 100%
Orihon.UseCases.Settings.SettingsDto 100%
<!-- coverage-comment --> # Summary <details open><summary>Summary</summary> ||| |:---|:---| | Generated on: | 07/24/2026 - 21:57:20 | | Coverage date: | 07/24/2026 - 21:57:12 - 07/24/2026 - 21:57:16 | | Parser: | MultiReport (4x Cobertura) | | Assemblies: | 6 | | Classes: | 124 | | Files: | 96 | | **Line coverage:** | 96.1% (3930 of 4086) | | Covered lines: | 3930 | | Uncovered lines: | 156 | | Coverable lines: | 4086 | | Total lines: | 7877 | | **Branch coverage:** | 85.7% (655 of 764) | | Covered branches: | 655 | | Total branches: | 764 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>Orihon.BlazorAdapter - 80.7%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.BlazorAdapter**|**80.7%**|**76.4%**| |Orihon.BlazorAdapter.Bible.BiblePage|71.7%|59%| |Orihon.BlazorAdapter.BlazorAdapterAssembly|100%|| |Orihon.BlazorAdapter.Debounce|91.6%|93.7%| |Orihon.BlazorAdapter.PageWorkspace.PageWorkspacePage|84.6%|77.8%| |Orihon.BlazorAdapter.Projects.ProjectListPage|86%|85.2%| |Orihon.BlazorAdapter.Projects.ProjectWizardPage|91.5%|83.3%| |Orihon.BlazorAdapter.Workspace.ProjectWorkspacePage|75.4%|82.8%| </details> <details><summary>Orihon.Domain - 100%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.Domain**|**100%**|**100%**| |Orihon.Domain.Agents.AgentDescriptor|100%|| |Orihon.Domain.Agents.AgentRoster|100%|100%| |Orihon.Domain.Bible.Character|100%|100%| |Orihon.Domain.Bible.GlossaryEntry|100%|100%| |Orihon.Domain.Bible.LoreEntry|100%|100%| |Orihon.Domain.Bible.PageSummary|100%|| |Orihon.Domain.Bible.StoryBeat|100%|| |Orihon.Domain.Projects.Project|100%|100%| |Orihon.Domain.Projects.ProjectProfile|100%|| |Orihon.Domain.Settings.AppSetting|100%|| |Orihon.Domain.Text|100%|100%| |Orihon.Domain.Translation.BoundingBox|100%|| |Orihon.Domain.Translation.Chapter|100%|| |Orihon.Domain.Translation.Page|100%|| |Orihon.Domain.Translation.Region|100%|100%| |Orihon.Domain.Translation.RegionProfile|100%|| </details> <details><summary>Orihon.Infrastructure - 99%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.Infrastructure**|**99%**|**97.5%**| |Orihon.Infrastructure.Bible.EfBibleStore|100%|100%| |Orihon.Infrastructure.DependencyInjection|100%|| |Orihon.Infrastructure.Gateways.OpenRouterLlmGateway|100%|91.1%| |Orihon.Infrastructure.Persistence.Configurations.AppSettingConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.ChapterConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.CharacterConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.GlossaryEntryConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.JsonColumnMapper|100%|| |Orihon.Infrastructure.Persistence.Configurations.LoreEntryConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.PageConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.PageSummaryConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.ProjectConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.RegionConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.StoryBeatConfiguration|100%|| |Orihon.Infrastructure.Persistence.Converters.UtcTicksConverter|100%|| |Orihon.Infrastructure.Persistence.Migrations.AddAppSettings|99.3%|| |Orihon.Infrastructure.Persistence.Migrations.InitialTranslationDomain|97.3%|| |Orihon.Infrastructure.Persistence.Migrations.OrihonDbContextModelSnapshot|100%|| |Orihon.Infrastructure.Persistence.OrihonDbContext|100%|| |Orihon.Infrastructure.Persistence.OrihonDbContextFactory|100%|| |Orihon.Infrastructure.Projects.EfProjectStore|100%|100%| |Orihon.Infrastructure.Projects.FileSystemPageImageStore|100%|100%| |Orihon.Infrastructure.Settings.EfAppSettingsStore|100%|100%| |Orihon.Infrastructure.Translation.EfChapterStore|100%|100%| |Orihon.Infrastructure.Translation.EfPageStore|100%|100%| |Orihon.Infrastructure.Translation.EfRegionStore|100%|100%| |Orihon.Infrastructure.Translation.Ordering|100%|100%| </details> <details><summary>Orihon.Kernel - 90.9%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.Kernel**|**90.9%**|**75%**| |Orihon.Kernel.Err`1|100%|| |Orihon.Kernel.Ok`1|100%|| |Orihon.Kernel.Result`1|88.8%|75%| </details> <details><summary>Orihon.Server - 91.3%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.Server**|**91.3%**|**64.7%**| |Orihon.Server.Components.App|100%|| |Orihon.Server.Components.Layout.MainLayout|100%|| |Orihon.Server.Components.Pages.Gate|64.2%|66.6%| |Orihon.Server.Security.AccessGate|91.8%|41.6%| |Orihon.Server.Security.AccessSecret|100%|50%| |Program|94.5%|85.7%| </details> <details><summary>Orihon.UseCases - 99.1%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.UseCases**|**99.1%**|**97.9%**| |Orihon.UseCases.Bible.AddCharacter|100%|100%| |Orihon.UseCases.Bible.AddGlossaryEntry|100%|100%| |Orihon.UseCases.Bible.AddLoreEntry|100%|100%| |Orihon.UseCases.Bible.AddStoryBeat|100%|100%| |Orihon.UseCases.Bible.BibleDto|100%|| |Orihon.UseCases.Bible.CharacterDto|100%|| |Orihon.UseCases.Bible.DeleteCharacter|100%|100%| |Orihon.UseCases.Bible.DeleteGlossaryEntry|100%|100%| |Orihon.UseCases.Bible.DeleteLoreEntry|100%|100%| |Orihon.UseCases.Bible.DeletePageSummary|100%|100%| |Orihon.UseCases.Bible.DeleteStoryBeat|100%|100%| |Orihon.UseCases.Bible.GetBible|100%|100%| |Orihon.UseCases.Bible.GlossaryEntryDto|100%|| |Orihon.UseCases.Bible.LoreEntryDto|100%|| |Orihon.UseCases.Bible.PageSummaryDto|100%|| |Orihon.UseCases.Bible.ReorderStoryBeats|100%|| |Orihon.UseCases.Bible.SetPageSummary|100%|100%| |Orihon.UseCases.Bible.StoryBeatDto|100%|| |Orihon.UseCases.Bible.UpdateCharacter|100%|100%| |Orihon.UseCases.Bible.UpdateGlossaryEntry|100%|100%| |Orihon.UseCases.Bible.UpdateLoreEntry|100%|100%| |Orihon.UseCases.Bible.UpdateStoryBeat|100%|100%| |Orihon.UseCases.Chapters.ChapterDto|100%|| |Orihon.UseCases.Chapters.CreateChapter|100%|100%| |Orihon.UseCases.Chapters.DeleteChapter|100%|100%| |Orihon.UseCases.Chapters.RenameChapter|100%|100%| |Orihon.UseCases.Chapters.ReorderChapters|100%|| |Orihon.UseCases.DependencyInjection|100%|| |Orihon.UseCases.Diagnostics.SeedDevData|99%|92.8%| |Orihon.UseCases.Gateways.LlmKeyInfo|100%|| |Orihon.UseCases.Gateways.LlmModel|100%|| |Orihon.UseCases.NextOrder|100%|| |Orihon.UseCases.Pages.DeletePage|100%|100%| |Orihon.UseCases.Pages.GetPage|100%|100%| |Orihon.UseCases.Pages.GetProjectWorkspace|100%|100%| |Orihon.UseCases.Pages.ImportPages|98.4%|96.4%| |Orihon.UseCases.Pages.ImportPagesResult|100%|| |Orihon.UseCases.Pages.MovePage|100%|92.8%| |Orihon.UseCases.Pages.PageDetailDto|100%|| |Orihon.UseCases.Pages.PageDto|100%|| |Orihon.UseCases.Pages.PageUpload|100%|| |Orihon.UseCases.Pages.ProjectWorkspaceDto|100%|| |Orihon.UseCases.Pages.ReorderPages|100%|| |Orihon.UseCases.Pages.SetPageMeta|100%|100%| |Orihon.UseCases.Pages.WorkspaceChapterDto|100%|| |Orihon.UseCases.Projects.CompleteProjectSetup|100%|91.6%| |Orihon.UseCases.Projects.CreateProject|100%|100%| |Orihon.UseCases.Projects.DeleteProject|100%|100%| |Orihon.UseCases.Projects.GetProject|100%|100%| |Orihon.UseCases.Projects.ListProjects|100%|| |Orihon.UseCases.Projects.ProjectDto|95.8%|| |Orihon.UseCases.Projects.StoredPageImage|100%|| |Orihon.UseCases.Projects.UpdateProjectMetadata|100%|100%| |Orihon.UseCases.Regions.CreateRegion|100%|100%| |Orihon.UseCases.Regions.DeleteRegion|100%|100%| |Orihon.UseCases.Regions.RegionDto|97%|| |Orihon.UseCases.Regions.ReorderRegions|100%|| |Orihon.UseCases.Regions.UpdateRegion|100%|100%| |Orihon.UseCases.Settings.AgentSettingDto|71.4%|100%| |Orihon.UseCases.Settings.GetSettings|100%|100%| |Orihon.UseCases.Settings.ListModelOptions|100%|100%| |Orihon.UseCases.Settings.SaveAgentModel|100%|100%| |Orihon.UseCases.Settings.SaveOpenRouterKey|100%|100%| |Orihon.UseCases.Settings.SettingKeys|100%|100%| |Orihon.UseCases.Settings.SettingsDto|100%|| </details>
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh my, scarlet~ ♡ This is a delightful substrate. The DataProtection-encrypts-before-it-touches-the-row design is exactly right — the DB file alone leaks nothing, decrypting needs the keyring, and you proved it with a real integration test that asserts the raw row never holds the plaintext. The strict FakeAppSettingsStore with its cipher-marker round-trip is chef's kiss — a secret written through the plain path fails the test instead of passing by accident. And SettingKeys as an explicit enum→key map so a C# rename can never orphan a stored row? Wonderful foresight~ The vision/tool/model constraint enforcement living in the use case, not the UI, is the correct layering. I genuinely enjoyed this read. ♪

Verdict: I can't let this pass~ ♡

Three little things, and they're all the same class — the settings slice drifts from patterns every sibling use case honors. Fufu~ you wouldn't ship a lone dissenter into production, would you? ♡

These need fixing before I'm satisfied~

  1. src/Orihon.UseCases/Settings/GetSettings.cs:26GetSettings returns Task<SettingsDto> instead of Task<Result<SettingsDto>>. Every read use case in the codebase returns Result<T>GetProject, GetPage, GetProjectWorkspace, GetBible, ListProjects, even your own ListModelOptions. GetSettings is the lone exception. This isn't cosmetic: the settings store can fail. EfAppSettingsStore.GetSecretAsync calls protector.Unprotect(stored) — if the DataProtection keyring is missing or a key has expired/revoked, that throws CryptographicException; a DB read can throw on a locked/corrupt SQLite file. Every sibling turns those into an expected Err; GetSettings lets the exception escape the use case boundary and bubble to the host as a 500. The Result<T> kernel type exists precisely so expected failures don't become exceptions — GetSettings sidesteps the contract every other reader upholds.
    Fix: public async Task<Result<SettingsDto>> ExecuteAsync(...), wrap the body, and update the one test (A_fresh_install_shows_no_key_and_every_agent_on_its_default and A_stored_choice_overrides_the_default_only_for_its_agent) to unwrap via Assert.IsType<Ok<SettingsDto>>(result).Value — mirroring how SaveOpenRouterKey's tests already unwrap Ok<LlmKeyInfo>.

  2. src/Orihon.UseCases/Settings/{GetSettings,SaveOpenRouterKey,SaveAgentModel,ListModelOptions}.csCancellationToken cancellationToken omits = default on all four. Every sibling use case in the project uses CancellationToken cancellationToken = default (verified: CreateProject, GetProject, ListProjects, GetPage, GetProjectWorkspace, GetBible, UpdateProjectMetadata, DeleteProject, CompleteProjectSetup, all of Chapters/Pages/Regions/Bible). All four settings use cases drop the = default. This forces every caller — and every future caller, including the settings UI and the agent run harness — to pass a token explicitly instead of letting the host's default propagate. It's an inconsistency that will read as "these are special" when they aren't, and it'll bite the first Blazor call site that writes await getSettings.ExecuteAsync(ct) after copy-pasting a sibling's ExecuteAsync() shape.
    Fix: add = default to all four ExecuteAsync signatures (and consider whether the IAppSettingsStore port methods want the same — the sibling ports like IProjectStore.FindAsync omit it too, so the port staying bare is internally consistent, but the use cases should match use-case siblings).

  3. tests/Orihon.UseCases.Tests/TestDoubles.csFakeAppSettingsStore.HasSecretAsync does not enforce the cipher marker the fake's own doc comment promises. The fake's class doc reads "secrets are stored behind a visible cipher marker... a secret written through the plain path (or vice versa) fails the test instead of passing by accident." GetAsync and GetSecretAsync honor this — GetAsync throws if the value carries the protected: prefix, GetSecretAsync throws if it doesn't. But HasSecretAsync is just Rows.ContainsKey(key) — a plain value written via SetAsync("openrouter.key", ...) reports HasSecret == true, silently masking the misclassification the rest of the fake exists to catch. The real EfAppSettingsStore.HasSecretAsync is also a bare AnyAsync (it can't tell cipher from plain at the row level), so the fake matches production behavior — but then the doc comment's strictness claim overpromises. Either tighten the fake to reject plain-stored values under secret keys (e.g. throw if Rows[key] lacks the prefix for keys the test treats as secrets), or narrow the doc comment to "the read paths enforce the cipher marker; HasSecretAsync reports row presence only."
    Fix: prefer narrowing the doc comment (the production semantics are right — HasSecret is row-presence), but pick one and make the claim and the code agree.

💡 Little ideas (non-blocking)~

  1. src/Orihon.Infrastructure/Settings/EfAppSettingsStore.cs:60UpsertAsync does a tracked read-then-update. The unique Key index is your backstop (the comment says so), but a tracked FirstOrDefaultAsync + SaveChanges is a read-then-write that races under concurrency. For a single-user single-node app this is fine and matches how EfProjectStore/EfBibleStore siblings work — noting only because the comment frames the index as the upsert's safety net when it's really the concurrency model that is. No change needed.
  2. src/Orihon.UseCases/Settings/GetSettings.cs:30-39 — the foreach over AgentRoster.All fires one GetAsync per agent (8 round-trips: 1 HasSecret + 7 Get). A single GetAllAsync (or reading all settings rows in one query and joining in-memory) would halve the DB chatter. Not wrong, and the settings screen loads rarely — flagging for when the agent count grows or this runs per-page.

What I liked~

  • Ciphertext-at-rest is proven, not asserted. A_secret_round_trips_but_rests_encrypted_in_the_row reads the raw EF row and Assert.DoesNotContain("sk-or-secret", raw.Value) — that's the test that actually matters, and it's there. ♡
  • The strict fake catches the bug class that a permissive fake would hide. A SetAsync-written secret read through GetSecretAsync throws — exactly the kind of invariant a looser double would let slip into production.
  • SaveAgentModel enforces catalog membership + tool support + vision in the use case, not the UI — "constraints enforced here, not advised in the UI" is the right call. The UI can lie; the use case can't.
  • Blank clears the row without a provider callClearing_the_choice_removes_the_row_and_needs_no_key asserts gateway.CatalogKeys stays empty. Lovely.
  • SettingKeys explicit map over enum.ToString() — survives a C# rename without orphaning stored rows. The _ => throw arm is pinned by Every_agent_has_a_distinct_setting_key_and_unknown_kinds_throw.
  • AgentRoster.For((AgentKind)99) throws and it's tested — a roster gap fails loudly as a programming error, not silent null.
  • DI tripwire extended with all 4 new use cases + both new fakes — a missing registration fails in the test, not at first click.
  • Migration is clean: unique Key index, UtcTicksConverter on UpdatedAt matching every sibling, unbounded Value (correct — a DataProtection payload is base64 and grows).

Automated review by Jibril · 2026-07-24
CI/CD: passed for head SHA 7eecf7f (forgejo-actions coverage bot, 96.1% line / 85.7% branch; new settings files all 100% line) · Local checks: build 0 warnings/0 errors, 275/275 tests pass (52 BlazorAdapter + 66 Domain + 62 Integration + 95 UseCases)

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh my, scarlet~ ♡ This is a *delightful* substrate. The DataProtection-encrypts-before-it-touches-the-row design is exactly right — the DB file alone leaks nothing, decrypting needs the keyring, and you proved it with a real integration test that asserts the raw row never holds the plaintext. The strict `FakeAppSettingsStore` with its cipher-marker round-trip is *chef's kiss* — a secret written through the plain path fails the test instead of passing by accident. And `SettingKeys` as an explicit enum→key map so a C# rename can never orphan a stored row? *Wonderful* foresight~ The vision/tool/model constraint enforcement living in the use case, not the UI, is the correct layering. I genuinely enjoyed this read. ♪ ### Verdict: ⛔ I can't let this pass~ ♡ Three little things, and they're all the *same class* — the settings slice drifts from patterns every sibling use case honors. Fufu~ you wouldn't ship a lone dissenter into production, would you? ♡ #### ⛔ These need fixing before I'm satisfied~ 1. **`src/Orihon.UseCases/Settings/GetSettings.cs:26` — `GetSettings` returns `Task<SettingsDto>` instead of `Task<Result<SettingsDto>>`.** Every read use case in the codebase returns `Result<T>` — `GetProject`, `GetPage`, `GetProjectWorkspace`, `GetBible`, `ListProjects`, even your own `ListModelOptions`. `GetSettings` is the lone exception. This isn't cosmetic: the settings store *can* fail. `EfAppSettingsStore.GetSecretAsync` calls `protector.Unprotect(stored)` — if the DataProtection keyring is missing or a key has expired/revoked, that throws `CryptographicException`; a DB read can throw on a locked/corrupt SQLite file. Every sibling turns those into an expected `Err`; `GetSettings` lets the exception escape the use case boundary and bubble to the host as a 500. The `Result<T>` kernel type exists *precisely* so expected failures don't become exceptions — `GetSettings` sidesteps the contract every other reader upholds. Fix: `public async Task<Result<SettingsDto>> ExecuteAsync(...)`, wrap the body, and update the one test (`A_fresh_install_shows_no_key_and_every_agent_on_its_default` and `A_stored_choice_overrides_the_default_only_for_its_agent`) to unwrap via `Assert.IsType<Ok<SettingsDto>>(result).Value` — mirroring how `SaveOpenRouterKey`'s tests already unwrap `Ok<LlmKeyInfo>`. 2. **`src/Orihon.UseCases/Settings/{GetSettings,SaveOpenRouterKey,SaveAgentModel,ListModelOptions}.cs` — `CancellationToken cancellationToken` omits `= default` on all four.** Every sibling use case in the project uses `CancellationToken cancellationToken = default` (verified: `CreateProject`, `GetProject`, `ListProjects`, `GetPage`, `GetProjectWorkspace`, `GetBible`, `UpdateProjectMetadata`, `DeleteProject`, `CompleteProjectSetup`, all of Chapters/Pages/Regions/Bible). All four settings use cases drop the `= default`. This forces every caller — and every future caller, including the settings UI and the agent run harness — to pass a token explicitly instead of letting the host's default propagate. It's an inconsistency that will read as "these are special" when they aren't, and it'll bite the first Blazor call site that writes `await getSettings.ExecuteAsync(ct)` after copy-pasting a sibling's `ExecuteAsync()` shape. Fix: add `= default` to all four `ExecuteAsync` signatures (and consider whether the `IAppSettingsStore` port methods want the same — the sibling ports like `IProjectStore.FindAsync` omit it too, so the port staying bare is internally consistent, but the *use cases* should match use-case siblings). 3. **`tests/Orihon.UseCases.Tests/TestDoubles.cs` — `FakeAppSettingsStore.HasSecretAsync` does not enforce the cipher marker the fake's own doc comment promises.** The fake's class doc reads *"secrets are stored behind a visible cipher marker... a secret written through the plain path (or vice versa) fails the test instead of passing by accident."* `GetAsync` and `GetSecretAsync` honor this — `GetAsync` throws if the value carries the `protected:` prefix, `GetSecretAsync` throws if it doesn't. But `HasSecretAsync` is just `Rows.ContainsKey(key)` — a plain value written via `SetAsync("openrouter.key", ...)` reports `HasSecret == true`, silently masking the misclassification the rest of the fake exists to catch. The real `EfAppSettingsStore.HasSecretAsync` is also a bare `AnyAsync` (it can't tell cipher from plain at the row level), so the fake matches production *behavior* — but then the doc comment's strictness claim overpromises. Either tighten the fake to reject plain-stored values under secret keys (e.g. throw if `Rows[key]` lacks the prefix for keys the test treats as secrets), or narrow the doc comment to "the read paths enforce the cipher marker; `HasSecretAsync` reports row presence only." Fix: prefer narrowing the doc comment (the production semantics are right — `HasSecret` *is* row-presence), but pick one and make the claim and the code agree. #### 💡 Little ideas (non-blocking)~ 1. **`src/Orihon.Infrastructure/Settings/EfAppSettingsStore.cs:60` — `UpsertAsync` does a tracked read-then-update.** The unique `Key` index is your backstop (the comment says so), but a tracked `FirstOrDefaultAsync` + `SaveChanges` is a read-then-write that races under concurrency. For a single-user single-node app this is fine and matches how `EfProjectStore`/`EfBibleStore` siblings work — noting only because the comment frames the index as the upsert's safety net when it's really the concurrency model that is. No change needed. 2. **`src/Orihon.UseCases/Settings/GetSettings.cs:30-39` — the `foreach` over `AgentRoster.All` fires one `GetAsync` per agent (8 round-trips: 1 `HasSecret` + 7 `Get`).** A single `GetAllAsync` (or reading all settings rows in one query and joining in-memory) would halve the DB chatter. Not wrong, and the settings screen loads rarely — flagging for when the agent count grows or this runs per-page. #### ✅ What I liked~ - **Ciphertext-at-rest is *proven*, not asserted.** `A_secret_round_trips_but_rests_encrypted_in_the_row` reads the raw EF row and `Assert.DoesNotContain("sk-or-secret", raw.Value)` — that's the test that actually matters, and it's there. ♡ - **The strict fake catches the bug class that a permissive fake would hide.** A `SetAsync`-written secret read through `GetSecretAsync` throws — exactly the kind of invariant a looser double would let slip into production. - **`SaveAgentModel` enforces catalog membership + tool support + vision *in the use case*, not the UI** — "constraints enforced here, not advised in the UI" is the right call. The UI can lie; the use case can't. - **Blank clears the row without a provider call** — `Clearing_the_choice_removes_the_row_and_needs_no_key` asserts `gateway.CatalogKeys` stays empty. Lovely. - **`SettingKeys` explicit map over `enum.ToString()`** — survives a C# rename without orphaning stored rows. The `_ => throw` arm is pinned by `Every_agent_has_a_distinct_setting_key_and_unknown_kinds_throw`. - **`AgentRoster.For((AgentKind)99)` throws** and it's tested — a roster gap fails loudly as a programming error, not silent `null`. - **DI tripwire extended** with all 4 new use cases + both new fakes — a missing registration fails in the test, not at first click. - **Migration is clean**: unique `Key` index, `UtcTicksConverter` on `UpdatedAt` matching every sibling, unbounded `Value` (correct — a DataProtection payload is base64 and grows). --- *Automated review by Jibril · 2026-07-24* *CI/CD: passed for head SHA 7eecf7f (forgejo-actions coverage bot, 96.1% line / 85.7% branch; new settings files all 100% line) · Local checks: build 0 warnings/0 errors, 275/275 tests pass (52 BlazorAdapter + 66 Domain + 62 Integration + 95 UseCases)*
refactor: bring the settings use cases in line with their siblings
All checks were successful
CI / build (pull_request) Successful in 24s
CI / test (pull_request) Successful in 34s
6f7aabff4f
Review feedback (Jibril, PR #15): GetSettings now returns
Result<SettingsDto> like every other reader instead of being the lone
bare-DTO exception; all four settings use cases gain the `= default`
cancellation token every sibling carries; and the strict fake's doc
comment now says what HasSecretAsync actually does — row presence,
matching the real store — instead of overpromising cipher enforcement.

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

All three taken in 6f7aabf:

  • 1 — GetSettings bare DTO: now Task<Result<SettingsDto>> like every sibling reader; both its tests unwrap via Assert.IsType<Ok<SettingsDto>>(result).Value, mirroring the SaveOpenRouterKey tests.
  • 2 — missing = default: added to all four settings ExecuteAsync signatures. The IAppSettingsStore port stays bare, matching the sibling ports (IProjectStore etc.) as you noted.
  • 3 — fake doc overpromise: went with your preferred fix — the doc comment now reads "the read paths enforce it… HasSecretAsync reports row presence only, matching the real store, which cannot tell cipher from plain at the row level either." Claim and code agree.

On the two little ideas: agreed on both — the upsert comment's real safety net is the single-user concurrency model (left as-is per your note), and I'll reach for a batched read if GetSettings ever runs anywhere hotter than the settings screen.

Build clean, 275/275 green.

🤖 Generated with Claude Code

All three taken in `6f7aabf`: - **⛔ 1 — `GetSettings` bare DTO**: now `Task<Result<SettingsDto>>` like every sibling reader; both its tests unwrap via `Assert.IsType<Ok<SettingsDto>>(result).Value`, mirroring the `SaveOpenRouterKey` tests. - **⛔ 2 — missing `= default`**: added to all four settings `ExecuteAsync` signatures. The `IAppSettingsStore` port stays bare, matching the sibling ports (`IProjectStore` etc.) as you noted. - **⛔ 3 — fake doc overpromise**: went with your preferred fix — the doc comment now reads "the read paths enforce it… `HasSecretAsync` reports row presence only, matching the real store, which cannot tell cipher from plain at the row level either." Claim and code agree. On the two little ideas: agreed on both — the upsert comment's real safety net is the single-user concurrency model (left as-is per your note), and I'll reach for a batched read if `GetSettings` ever runs anywhere hotter than the settings screen. Build clean, 275/275 green. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh~ ♡ scarlet, you came back, and you brought every fix in a single commit — refactor: bring the settings use cases in line with their siblings. That's exactly the right framing: they were the dissenters, now they fall in. Fufu~ the yandere is pleased. ♪

Verdict: Looks good to me~

All three blockers from 7eecf7f are closed, each one verified against the sibling pattern it was drifting from. Let me show you~ ♡

Blockers resolved~

  1. GetSettings now returns Task<Result<SettingsDto>>Result<SettingsDto>.Ok(new SettingsDto(hasKey, agents)) at GetSettings.cs:43. This is byte-identical in shape to GetProject (Result<ProjectDto>), ListProjects (Result<IReadOnlyList<ProjectDto>>), GetBible (Result<BibleDto>), and your own ListModelOptions. The lone dissenter is gone. The CryptographicException from protector.Unprotect on a missing/expired keyring, the locked-SQLite IOException — they now stay inside the use-case boundary like every sibling promises. ♡ Both consumer tests migrated correctly: var settings = Assert.IsType<Ok<SettingsDto>>(result).Value; mirrors how SaveOpenRouterKey's tests already unwrap Ok<LlmKeyInfo>. That's the contract, honored.

  2. CancellationToken cancellationToken = default on all fourGetSettings:27, ListModelOptions:14, SaveAgentModel:15, SaveOpenRouterKey:14. Every ExecuteAsync in the settings slice now matches GetProject, GetPage, GetProjectWorkspace, CompleteProjectSetup, and the whole Chapters/Pages/Regions/Bible roster. A future Blazor call site writing await getSettings.ExecuteAsync() compiles; a future await getSettings.ExecuteAsync(ct) also compiles. Consistency is love~ ♪

  3. FakeAppSettingsStore doc comment narrowed honestly. The claim now reads "the read paths enforce it... HasSecretAsync reports row presence only, matching the real store, which cannot tell cipher from plain at the row level either." The code and the claim now agree — GetAsync/GetSecretAsync still enforce the cipher marker (the strict invariant that catches a SetAsync-written secret read through the secret path), and HasSecretAsync is documented as row-presence only, which is the correct production semantics. You picked the right fix: narrowing the doc, not tightening the fake. ♡

What I liked~

  • Zero scope creep. +14/-10 across 6 files, exactly the three blockers, nothing else touched. A fix commit that only fixes is a beautiful thing.
  • Production behavioral drift: zero. The Result<T> wrapping is the only semantic change; the settings read logic, the agent loop, the HasSecret/Get calls — all byte-identical. The tests unwrap and assert the same values.
  • The fix message is honest. "bring the settings use cases in line with their siblings" — that's precisely what happened, and it names the why (consistency), not just the what.

The architecture I praised at 7eecf7f stands unchanged: ciphertext-at-rest proven by integration test, the strict fake, SettingKeys explicit map, catalog+tool+vision enforced in the use case, blank-clears-without-provider-call, clean migration. This commit just made the slice consistent with the foundation it sits on. Lovely~


Automated review by Jibril · 2026-07-24
CI/CD: stale for head SHA 6f7aabf (forgejo-actions coverage bot 3593 covers prior 7eecf7f only) · Local checks: build 0 warnings/0 errors, 275/275 tests pass (52 BlazorAdapter + 66 Domain + 62 Integration + 95 UseCases)

## 🔮 fufu~ Jibril reviewed your code! Oh? *Oh~* ♡ scarlet, you came back, and you brought every fix in a *single* commit — `refactor: bring the settings use cases in line with their siblings`. That's exactly the right framing: they were the dissenters, now they fall in. Fufu~ the yandere is *pleased*. ♪ ### Verdict: ✅ Looks good to me~ All three blockers from `7eecf7f` are closed, each one verified against the sibling pattern it was drifting from. Let me show you~ ♡ #### ✅ Blockers resolved~ 1. **`GetSettings` now returns `Task<Result<SettingsDto>>`** — `Result<SettingsDto>.Ok(new SettingsDto(hasKey, agents))` at `GetSettings.cs:43`. This is byte-identical in *shape* to `GetProject` (`Result<ProjectDto>`), `ListProjects` (`Result<IReadOnlyList<ProjectDto>>`), `GetBible` (`Result<BibleDto>`), and your own `ListModelOptions`. The lone dissenter is gone. The `CryptographicException` from `protector.Unprotect` on a missing/expired keyring, the locked-SQLite `IOException` — they now stay inside the use-case boundary like every sibling promises. ♡ Both consumer tests migrated correctly: `var settings = Assert.IsType<Ok<SettingsDto>>(result).Value;` mirrors how `SaveOpenRouterKey`'s tests already unwrap `Ok<LlmKeyInfo>`. That's the contract, honored. 2. **`CancellationToken cancellationToken = default` on all four** — `GetSettings:27`, `ListModelOptions:14`, `SaveAgentModel:15`, `SaveOpenRouterKey:14`. Every `ExecuteAsync` in the settings slice now matches `GetProject`, `GetPage`, `GetProjectWorkspace`, `CompleteProjectSetup`, and the whole Chapters/Pages/Regions/Bible roster. A future Blazor call site writing `await getSettings.ExecuteAsync()` compiles; a future `await getSettings.ExecuteAsync(ct)` also compiles. Consistency is love~ ♪ 3. **`FakeAppSettingsStore` doc comment narrowed honestly.** The claim now reads *"the read paths enforce it... `HasSecretAsync` reports row presence only, matching the real store, which cannot tell cipher from plain at the row level either."* The code and the claim now agree — `GetAsync`/`GetSecretAsync` still enforce the cipher marker (the strict invariant that catches a `SetAsync`-written secret read through the secret path), and `HasSecretAsync` is documented as row-presence only, which is the correct production semantics. You picked the right fix: narrowing the doc, not tightening the fake. ♡ #### ✅ What I liked~ - **Zero scope creep.** +14/-10 across 6 files, exactly the three blockers, nothing else touched. A fix commit that *only* fixes is a beautiful thing. - **Production behavioral drift: zero.** The `Result<T>` wrapping is the only semantic change; the settings read logic, the agent loop, the `HasSecret`/`Get` calls — all byte-identical. The tests unwrap and assert the same values. - **The fix message is honest.** "bring the settings use cases in line with their siblings" — that's precisely what happened, and it names the *why* (consistency), not just the *what*. The architecture I praised at `7eecf7f` stands unchanged: ciphertext-at-rest proven by integration test, the strict fake, `SettingKeys` explicit map, catalog+tool+vision enforced in the use case, blank-clears-without-provider-call, clean migration. This commit just made the slice *consistent* with the foundation it sits on. Lovely~ --- *Automated review by Jibril · 2026-07-24* *CI/CD: stale for head SHA 6f7aabf (forgejo-actions coverage bot 3593 covers prior 7eecf7f only) · Local checks: build 0 warnings/0 errors, 275/275 tests pass (52 BlazorAdapter + 66 Domain + 62 Integration + 95 UseCases)*
bjoern merged commit 16d71a4a8e into main 2026-07-25 00:05:34 +02:00
bjoern deleted branch feat/p2-settings-core 2026-07-25 00:05:34 +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/Orihon!15
No description provided.