diff --git a/QuickFiler.Test/Controllers/KaStringAsyncTests.cs b/QuickFiler.Test/Controllers/KaStringAsyncTests.cs
index 525542b2d..9762cd715 100644
--- a/QuickFiler.Test/Controllers/KaStringAsyncTests.cs
+++ b/QuickFiler.Test/Controllers/KaStringAsyncTests.cs
@@ -89,13 +89,44 @@ public void KeyEquals_ContainsMatchWhileActivated_InvokesUpdateAndReturnsTrue()
result.Should().BeTrue("a substring match returns true");
updateArg
.Should()
- .Be("b", "Update receives Key.Substring(other.Length - 1, 1) => index 1 => \"b\"");
+ .Be(
+ "b",
+ "Update receives the last character of the matched span (Key.IndexOf(\"ab\", StringComparison.Ordinal) + other.Length - 1 = 1) => \"b\""
+ );
ka.Activated.Should()
.BeTrue(
"the contains-match branch returns before the trailing Activated = false reset"
);
}
+ [TestMethod]
+ public void KeyEquals_ContainsMatchAtNonPrefixIndex_InvokesUpdateWithLastMatchedCharacter()
+ {
+ // Intent: issue #583 regression. Branch 1's Update argument was computed as
+ // Key.Substring(other.Length - 1, 1), which is only correct when other is a prefix
+ // of Key. For Key="01" and other="1", the probe matches at index 1, not index 0.
+ // Before the fix this yielded "0"; after the fix it yields "1".
+
+ // Arrange
+ string updateArg = null;
+ var ka = NewKa("01", update: s => updateArg = s);
+ ka.Activated = true;
+
+ // Act
+ var result = ka.KeyEquals("1");
+
+ // Assert
+ result.Should().BeTrue("a substring match returns true");
+ updateArg
+ .Should()
+ .Be(
+ "1",
+ "Update receives the last character of the matched span (Key.IndexOf(\"1\", "
+ + "StringComparison.Ordinal) + other.Length - 1 = 1), not the pre-fix "
+ + "prefix-only offset that yielded \"0\""
+ );
+ }
+
[TestMethod]
public void KeyEquals_ContainsMatchWhileNotActivated_ReturnsTrueWithoutUpdate()
{
diff --git a/QuickFiler/Controllers/KaStringAsync.cs b/QuickFiler/Controllers/KaStringAsync.cs
index 9951e6002..a0132b09a 100644
--- a/QuickFiler/Controllers/KaStringAsync.cs
+++ b/QuickFiler/Controllers/KaStringAsync.cs
@@ -78,8 +78,10 @@ public bool Activated
///
///
/// Argument contract. must be non-null and non-empty. The
- /// guard clause at the top of this method rejects both fail-fast, so branch 1's substring
- /// offset expression is never evaluated with a negative start index.
+ /// guard clause at the top of this method rejects both fail-fast, so branch 1's derived
+ /// offset (Key.IndexOf(other) plus the matched length) is never evaluated with a
+ /// negative start index: IndexOf is non-negative because branch 1 only runs when
+ /// Contains already matched, and other.Length is at least 1 because of the guard above.
///
///
/// Consequence for callers. KbdActions methods whose key type is
@@ -125,7 +127,12 @@ public bool KeyEquals(string other)
if (Key.Contains(other))
{
if (Activated && Update is not null)
- Update(Key.Substring(other.Length - 1, 1));
+ Update(
+ Key.Substring(
+ Key.IndexOf(other, StringComparison.Ordinal) + other.Length - 1,
+ 1
+ )
+ );
return true;
}
else if (other.Length == 1)
diff --git a/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/coverage-baseline.md b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/coverage-baseline.md
new file mode 100644
index 000000000..00c477c4e
--- /dev/null
+++ b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/coverage-baseline.md
@@ -0,0 +1,135 @@
+# Coverage Baseline (P0-T8)
+
+- Timestamp: 2026-09-13T01-00
+- Command: collect --output
+ --output-format cobertura --settings --
+ QuickFiler.Test\bin\Debug\QuickFiler.Test.dll /InIsolation
+ /Settings:scripts\vscode\TaskMaster.cli.runsettings
+ "/TestCaseFilter:TestCategory!=LiveOutlook"
+- EXIT_CODE: 0
+
+## Deviation note — dotnet-coverage module-exclude settings
+
+The first attempt at this capture, using only the TaskMaster CLI runsettings file with no
+dotnet-coverage module excludes, failed 3 of 1393 tests
+(InitEmailQueue_ZeroBatchSize_ReturnsEmptyListWithoutThrowing,
+InitEmailQueue_ZeroBatchSize_StillStartsBackgroundWorker,
+InitEmailQueue_PositiveBatchSize_RetainsExistingProjectionAndFrameDrop) with a
+System.TypeInitializationException on Deedle.Reflection / netstandard, Version=2.1.0.0. This is
+a known, pre-existing, environment-level failure mode of dotnet-coverage's own instrumentation
+against Deedle/F# assemblies, unrelated to KaStringAsync and unrelated to this branch's diff
+(confirmed no source diff from origin/main at the time of this capture). The repository's own
+TaskMaster.runsettings already carries a matching Deedle/FSharp module-exclude block for the
+built-in Code Coverage collector; dotnet-coverage's own instrumentation does not read that file,
+so an equivalent exclude was supplied to dotnet-coverage collect via its own --settings file
+(ModulePaths/Exclude for .*Deedle.*, .*FSharp.*, .*Castle\.Core.*, .*FluentAssertions.*,
+.*Moq.*, .*Microsoft\.Testing.*, .*MSTest.*), written to a temp-directory file, never added to
+the repository. With that settings file supplied, the same capture reported "Test Run
+Successful.", 1393/1393 Passed.
+
+## Test run result
+
+```
+Test Run Successful.
+Total tests: 1393
+ Passed: 1393
+ Total time: 15.6062 Seconds
+```
+
+## Root coverage figures
+
+- Root line-rate: 0.4298999577286177
+- Root branch-rate: 0.24095967959333
+
+## KaStringAsync.cs covered/total
+
+- Covered: 60
+- Total: 60
+
+## Per-line hits projection
+
+| Line | Hits |
+|---|---|
+| 12 | 1 |
+| 14 | 1 |
+| 15 | 1 |
+| 16 | 1 |
+| 17 | 1 |
+| 18 | 1 |
+| 19 | 1 |
+| 20 | 1 |
+| 21 | 1 |
+| 22 | 1 |
+| 23 | 1 |
+| 24 | 1 |
+| 25 | 1 |
+| 26 | 1 |
+| 27 | 1 |
+| 32 | 1 |
+| 33 | 1 |
+| 39 | 1 |
+| 40 | 1 |
+| 46 | 1 |
+| 47 | 1 |
+| 50 | 1 |
+| 53 | 1 |
+| 54 | 1 |
+| 107 | 1 |
+| 110 | 1 |
+| 111 | 1 |
+| 112 | 1 |
+| 115 | 1 |
+| 116 | 1 |
+| 117 | 1 |
+| 118 | 1 |
+| 119 | 1 |
+| 120 | 1 |
+| 121 | 1 |
+| 122 | 1 |
+| 125 | 1 |
+| 126 | 1 |
+| 127 | 1 |
+| 128 | 1 |
+| 129 | 1 |
+| 131 | 1 |
+| 132 | 1 |
+| 133 | 1 |
+| 134 | 1 |
+| 135 | 1 |
+| 136 | 1 |
+| 137 | 1 |
+| 138 | 1 |
+| 139 | 1 |
+| 140 | 1 |
+| 141 | 1 |
+| 142 | 1 |
+| 143 | 1 |
+| 144 | 1 |
+| 145 | 1 |
+| 150 | 1 |
+| 151 | 1 |
+| 157 | 1 |
+| 158 | 1 |
+
+## Raw output disposition
+
+- Raw-output location (relative to the per-user temp directory root): p583-p0t8b/coverage.cobertura.xml
+- Prefix comparison (raw-output full path begins with repository root full path): False
+- Post-deletion existence check of the raw file: False (absent)
+- Directory listing of evidence/baseline/ after deletion (entry names only):
+ - coverage-tool-probe.md
+ - csharpier-check.md
+ - dotnet-bootstrap.md
+ - msbuild-analyzers.md
+ - msbuild-nullable.md
+ - nuget-restore.md
+ - phase0-instructions-read.md
+
+## Output Summary
+
+Exit code 0; inner test run "Test Run Successful." with 0 Failed (1393/1393 Passed, after
+supplying a dotnet-coverage module-exclude settings file to work around a known pre-existing
+Deedle/FSharp instrumentation crash unrelated to this change); root line-rate
+0.4298999577286177, root branch-rate 0.24095967959333; KaStringAsync.cs covered/total 60/60;
+per-line hits projection holds 60 rows, no repeated line number; raw output confirmed outside
+the repository root and deleted; evidence/baseline/ directory listing contains no .xml entry.
diff --git a/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/coverage-tool-probe.md b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/coverage-tool-probe.md
new file mode 100644
index 000000000..c7d49d84c
--- /dev/null
+++ b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/coverage-tool-probe.md
@@ -0,0 +1,17 @@
+# Coverage Tool Probe
+
+- Timestamp: 2026-09-13T00-15
+- Command: Get-Command dotnet-coverage(.exe), then its version switch
+- EXIT_CODE: 0
+
+## Result
+
+- Present: yes (already installed as a global dotnet tool; no install step was needed)
+- Resolved path: \.dotnet\tools\dotnet-coverage.exe
+- Version string: 18.10.0+f4cc39224845ffa74bf246c9da2399d50e5d6342
+
+## Output Summary
+
+dotnet-coverage is present as a global tool, resolved path recorded above, version string
+18.10.0, observed exit code 0 for the version-switch invocation, matching the recorded shape at
+docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/baseline/coverage-tool-probe.2026-08-22T09-32.md.
diff --git a/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/csharpier-check.md b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/csharpier-check.md
new file mode 100644
index 000000000..9a352032b
--- /dev/null
+++ b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/csharpier-check.md
@@ -0,0 +1,20 @@
+# CSharpier Check (Baseline)
+
+- Timestamp: 2026-09-13T00-20
+- Command: tool run csharpier check . (run from repository root,
+ read-only)
+- EXIT_CODE: 0
+
+## Verbatim output
+
+```
+Checked 1624 files in 5999ms.
+```
+
+## Output Summary
+
+Files-checked count: 1624. Files-needing-formatting count: 0 (no per-file line appeared ahead
+of the summary line). Exit code 0, matching the recorded clean-tree shape ("Checked 1517 files
+in 6621ms.", exit code 0) at
+docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/baseline/csharpier-check.2026-08-22T09-19.md;
+the higher file count here reflects the larger repository tree at this later point in time.
diff --git a/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/dotnet-bootstrap.md b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/dotnet-bootstrap.md
new file mode 100644
index 000000000..f2b527d99
--- /dev/null
+++ b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/dotnet-bootstrap.md
@@ -0,0 +1,31 @@
+# Dotnet Bootstrap
+
+- Timestamp: 2026-09-13T00-05
+
+## Step 1 — SDK resolution
+
+The per-worktree .dotnet-sdk junction did not resolve (Test-Path returned False before this
+task ran). Fallback provisioner scripts/vscode/Install-RepoDotNetSdk.ps1 was run.
+
+- Command: pwsh -NoProfile -File scripts/vscode/Install-RepoDotNetSdk.ps1
+- Output: "Downloading .NET SDK 8.0.205 from
+ https://builds.dotnet.microsoft.com/dotnet/Sdk/8.0.205/dotnet-sdk-8.0.205-win-x64.zip...",
+ "Installed repo-local .NET SDK 8.0.205 to
+ \.dotnet-sdk."
+- Resolved dotnet executable: .dotnet-sdk/dotnet.exe (repo-relative), version switch confirms
+ 8.0.205.
+- EXIT_CODE: 0
+
+## Step 2 — Tool restore
+
+- Command: tool restore (run from repository root)
+- EXIT_CODE: 0
+- Output: "Tool 'csharpier' (version '1.2.6') was restored. Available commands: csharpier" /
+ "Restore was successful." — matches the recorded success shape at
+ docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/baseline/dotnet-tool-restore.2026-08-22T09-18.md.
+
+## Output Summary
+
+Both steps succeeded: repo-local .NET SDK 8.0.205 installed for this worktree; CSharpier 1.2.6
+restored via dotnet-tools.json manifest, exit code 0, matching the archived precedent's success
+text verbatim.
diff --git a/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/kbdactions-baseline.md b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/kbdactions-baseline.md
new file mode 100644
index 000000000..b21be60df
--- /dev/null
+++ b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/kbdactions-baseline.md
@@ -0,0 +1,38 @@
+# KbdActionsTests Baseline (P0-T9)
+
+- Timestamp: 2026-09-13T01-05
+- Command: QuickFiler.Test\bin\Debug\QuickFiler.Test.dll
+ /InIsolation /Settings:scripts\vscode\TaskMaster.cli.runsettings
+ "/TestCaseFilter:FullyQualifiedName~KbdActionsTests"
+- EXIT_CODE: 0
+
+## Verbatim result
+
+```
+Test Run Successful.
+Total tests: 4
+ Passed: 4
+ Total time: 1.2679 Seconds
+```
+
+## Per-class reporting
+
+| Class | Total | Passed | Failed | Exit |
+|---|---|---|---|---|
+| KbdActionsTests | 4 | 4 | 0 | 0 |
+
+Individual tests: Add_WhenSourceAndStoredKeysAreDistinct_DoesNotTreatSubstringAsDuplicate,
+Add_WhenSourceAndStoredKeyAreExactDuplicate_ThrowsArgumentException,
+EnumerableConstructor_WhenStoredKeysDifferButKeyEqualsOverlaps_DoesNotThrow,
+FilterKeys_WhenDistinctStoredKeysCoexist_PreservesKeyboardMatchingSemantics (the pinned test).
+
+## Output Summary
+
+Exit code 0; verdict "Test Run Successful."; 4 Passed, 0 Failed for the KbdActionsTests class,
+including the pinned test FilterKeys_WhenDistinctStoredKeysCoexist_PreservesKeyboardMatchingSemantics.
+This baseline count (4) is expected to differ from the archived precedent's count of 3 on
+2026-08-22 (docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/baseline/vstest-baseline.2026-08-22T09-18.md),
+because one test method (Add_WhenSourceAndStoredKeyAreExactDuplicate_ThrowsArgumentException or
+a sibling) has been added to the pinned file since that baseline was recorded; per the plan's
+own acceptance text this task's count is not compared against that archived figure. No file was
+diffed to obtain this result; it is derived solely from the test-run outcome.
diff --git a/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/msbuild-analyzers.md b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/msbuild-analyzers.md
new file mode 100644
index 000000000..828311cfe
--- /dev/null
+++ b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/msbuild-analyzers.md
@@ -0,0 +1,34 @@
+# MSBuild Analyzer Rebuild (Baseline)
+
+- Timestamp: 2026-09-13T00-30
+- Command: MSBuild.exe (VS18) TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug
+ "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true
+ /flp:logfile=;verbosity=detailed
+- EXIT_CODE: 0
+
+## Verdict line
+
+```
+Build succeeded.
+ 0 Warning(s)
+ 0 Error(s)
+```
+
+## Log-line counts (from the detailed file logger)
+
+- "Skipping target ... CoreCompile" occurrences: 0
+- "CoreCompile:" occurrences: 130 (proves the rebuild was not vacuous)
+- Total warning count: 0 (console verdict "0 Warning(s)"; no analyzer/compiler warning lines
+ present in the detailed log for this run — the five System.Reactive
+ PackagesConfigCheck.targets target-invocation trace lines present in the log are target-graph
+ trace lines, not warning diagnostics, and none is followed by a warning-severity message text)
+
+## Output Summary
+
+Exit code 0; verdict "Build succeeded."; 0 "Skipping target CoreCompile" occurrences; 130
+"CoreCompile:" occurrences (non-vacuous rebuild); recorded warning count is 0, which is the
+ceiling P5-T3 compares against. This differs numerically from the archived precedent's 5
+System.Reactive packages.config warnings at
+docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/baseline/msbuild-analyzers.2026-08-22T09-21.md,
+but is consistent in kind (0 skip / >=9 CoreCompile / 0 errors); the numeric warning count is
+recorded as observed rather than assumed to match the archived figure.
diff --git a/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/msbuild-nullable.md b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/msbuild-nullable.md
new file mode 100644
index 000000000..085c20783
--- /dev/null
+++ b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/msbuild-nullable.md
@@ -0,0 +1,27 @@
+# MSBuild Nullable Rebuild (Baseline)
+
+- Timestamp: 2026-09-13T00-35
+- Command: MSBuild.exe (VS18) TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug
+ "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true
+ /flp:logfile=;verbosity=detailed
+- EXIT_CODE: 0
+
+## Verdict line
+
+```
+Build succeeded.
+ 0 Warning(s)
+ 0 Error(s)
+```
+
+## Log-line count
+
+- "Skipping target ... CoreCompile" occurrences: 0
+
+## Output Summary
+
+Exit code 0; verdict "Build succeeded."; "Skipping target CoreCompile" count of exactly 0
+(141 "CoreCompile:" occurrences, confirming the rebuild was not vacuous). Matches the recorded
+shape (0 skip / 0 errors) at
+docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/baseline/msbuild-nullable.2026-08-22T09-23.md.
+No nullable opt-in property (Nullable=enable) was passed, per the Command Reference.
diff --git a/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/nuget-restore.md b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/nuget-restore.md
new file mode 100644
index 000000000..a4591bce7
--- /dev/null
+++ b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/nuget-restore.md
@@ -0,0 +1,44 @@
+# NuGet Restore
+
+- Timestamp: 2026-09-13T00-10
+- Command: MSBuild.exe (VS18) TaskMaster.sln /t:Restore /m /p:RestorePackagesConfig=true (run
+ from repository root)
+- EXIT_CODE: 0
+
+## Verbatim summary lines
+
+```
+Installed:
+ 172 package(s) to packages.config projects
+Build succeeded.
+ 0 Warning(s)
+ 0 Error(s)
+```
+
+## Package-directory count verification
+
+Command: (Get-ChildItem -Path packages -Directory).Count
+Result: 172
+
+## Output Summary
+
+Restore command exit code 0; 172 packages installed to a fresh (previously absent) packages
+directory in this worktree; post-restore package-directory count is 172, at or above the
+required floor of 100 (and consistent in kind with the archived precedent's 265-directory /
+150-floor observation, allowing for this worktree's smaller pre-existing package set before this
+restore).
+
+## Pre-existing analyzer HintPath skew — provisioned, no tracked file touched
+
+15 first-party csproj files (including QuickFiler/QuickFiler.csproj and
+QuickFiler.Test/QuickFiler.Test.csproj) reference `` HintPath
+Meziantou.Analyzer.3.0.203, a version the fresh packages.config restore above did not install
+(it installed only 3.0.235). Confirmed pre-existing: `git diff --name-only
+origin/main...HEAD -- "*.csproj" "*/packages.config"` returned no output, so this branch did
+not introduce the skew. Remedy: `nuget install Meziantou.Analyzer -Version 3.0.203
+-OutputDirectory packages -DependencyVersion Ignore`, exit code 0, installed into the
+gitignored packages/ directory. `git status --porcelain -- packages "*.csproj"
+"*/packages.config"` is empty, so no tracked file changed. All five analyzer package families
+(Meziantou.Analyzer, Roslynator.Analyzers, SonarAnalyzer.CSharp, AsyncFixer,
+Microsoft.CodeAnalysis.BannedApiAnalyzers) referenced anywhere in the solution's csproj files
+now resolve to a present packages/ directory.
diff --git a/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/phase0-instructions-read.md b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/phase0-instructions-read.md
new file mode 100644
index 000000000..5a4d32c35
--- /dev/null
+++ b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/phase0-instructions-read.md
@@ -0,0 +1,25 @@
+# Phase 0 — Instructions Read
+
+- Timestamp: 2026-09-13T00-00
+- Policy Order: (1) CLAUDE.md, (2) .claude/rules/general-code-change.md, (3)
+ .claude/rules/general-unit-test.md, (4) .claude/rules/quality-tiers.md, (5)
+ .claude/rules/tonality.md, (6) .claude/rules/csharp.md
+
+## Files read, in full, end to end
+
+1. CLAUDE.md
+2. .claude/rules/general-code-change.md
+3. .claude/rules/general-unit-test.md
+4. .claude/rules/quality-tiers.md
+5. .claude/rules/tonality.md
+6. .claude/rules/csharp.md
+7. docs/features/active/kastringasync-keyequals-contains-offset-583/issue.md
+8. docs/features/active/kastringasync-keyequals-contains-offset-583/spec.md
+9. docs/features/active/kastringasync-keyequals-contains-offset-583/research/2026-09-12T10-35-kastringasync-keyequals-contains-offset-research.md
+
+## Source files read as plain context (not write targets of this task)
+
+10. QuickFiler/Controllers/KaStringAsync.cs
+11. QuickFiler.Test/Controllers/KaStringAsyncTests.cs
+
+No policy file was modified by this task.
diff --git a/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/ac-status-summary.md b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/ac-status-summary.md
new file mode 100644
index 000000000..fd1c92979
--- /dev/null
+++ b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/ac-status-summary.md
@@ -0,0 +1,26 @@
+# Acceptance Criteria Status Summary (P5-T14)
+
+- Timestamp: 2026-09-13T02-25
+
+### Acceptance Criteria Status
+
+- Source: docs/features/active/kastringasync-keyequals-contains-offset-583/spec.md
+- Total AC items: 6
+- Checked off (delivered): 6
+- Remaining (unchecked): 0
+- Items remaining: none
+
+## Per-criterion verification basis
+
+- AC1: verified by P2-T1's diff (guard text unchanged, no StartsWith introduced).
+- AC2: verified by P2-T1's diff and P4-T1's green run.
+- AC3: verified by P1-T1 (test added) and P4-T1 (passes post-fix).
+- AC4: verified by P3-T1's reword (asserted value unchanged) and P4-T1's green run.
+- AC5: verified by P0-T9 and P5-T6's matching Passed counts (0 delta).
+- AC6: verified by P5-T1 through P5-T7's four gate artifacts (CSharpier check, analyzer
+ rebuild, nullable rebuild, MSTest run, coverage delta).
+
+## Output Summary
+
+Total AC items 6; Checked off 6; Remaining 0. All six acceptance criteria in spec.md are
+checked.
diff --git a/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/coverage-delta.md b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/coverage-delta.md
new file mode 100644
index 000000000..9c04387e4
--- /dev/null
+++ b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/coverage-delta.md
@@ -0,0 +1,173 @@
+# Coverage Delta (P5-T7)
+
+- Timestamp: 2026-09-13T02-20
+
+Computed entirely from the two markdown evidence artifacts (coverage-baseline.md at P0-T8,
+coverage-postchange.md at P5-T5); no raw coverage document was read (both raw outputs were
+deleted by the tasks that produced them).
+
+## (a) Instrumented-run line-rate / branch-rate delta
+
+| | Baseline (P0-T8) | Post-change (P5-T5) | Delta |
+|---|---|---|---|
+| Root line-rate | 0.4298999577286177 | 0.4300204268507431 | +0.0001204691221254 |
+| Root branch-rate | 0.24095967959333 | 0.24095967959333 | 0 |
+
+## (b) KaStringAsync.cs covered/total, before and after
+
+| | Baseline (P0-T8) | Post-change (P5-T5) |
+|---|---|---|
+| Covered | 60 | 65 |
+| Total | 60 | 65 |
+
+Both 100% (60/60 baseline, 65/65 post-change); the 5-line increase in the total is the new
+regression test causing 5 additional lines (the multi-line rewritten Update(...) call body) to
+be counted as executable, replacing the 1-line pre-fix call.
+
+## (c) Changed-line coverage
+
+Anchored zero-context diff of QuickFiler/Controllers/KaStringAsync.cs against origin/main:
+
+```
+@@ -81,2 +81,4 @@ namespace QuickFiler.Controllers
+- /// guard clause at the top of this method rejects both fail-fast, so branch 1's substring
+- /// offset expression is never evaluated with a negative start index.
++ /// guard clause at the top of this method rejects both fail-fast, so branch 1's derived
++ /// offset (Key.IndexOf(other) plus the matched length) is never evaluated with a
++ /// negative start index: IndexOf is non-negative because branch 1 only runs when
++ /// Contains already matched, and other.Length is at least 1 because of the guard above.
+@@ -128 +130,6 @@ namespace QuickFiler.Controllers
+- Update(Key.Substring(other.Length - 1, 1));
++ Update(
++ Key.Substring(
++ Key.IndexOf(other, StringComparison.Ordinal) + other.Length - 1,
++ 1
++ )
++ );
+```
+
+Added (post-change) line numbers: 81, 82, 83, 84, 130, 131, 132, 133, 134, 135.
+
+Cross-referenced against coverage-postchange.md's per-line hits projection (that file's
+executable lines only):
+
+| Added line | Row present in projection? | Hits |
+|---|---|---|
+| 81 | No (non-executable — doc comment) | excluded |
+| 82 | No (non-executable — doc comment) | excluded |
+| 83 | No (non-executable — doc comment) | excluded |
+| 84 | No (non-executable — doc comment) | excluded |
+| 130 | Yes | 1 |
+| 131 | Yes | 1 |
+| 132 | Yes | 1 |
+| 133 | Yes | 1 |
+| 134 | Yes | 1 |
+| 135 | Yes | 1 |
+
+- Denominator (added lines appearing as a row in the projection): 6 (130-135)
+- Numerator (of those, hits >= 1): 6
+- Changed-line coverage: 6 / 6 = 100%
+- Excluded as non-executable (added lines with no row in the projection): 81, 82, 83, 84 — the
+ reworded doc-comment lines from P3-T2, as expected.
+
+## Per-line hits projection comparison (baseline vs. post-change)
+
+Each baseline line number outside the removed hunk range is mapped to its post-change line
+number by the net added-minus-removed shift of the hunks that precede it:
+
+- Lines 1-80 (before both hunks): shift 0.
+- Lines 83-127 (after hunk 1's removed range 81-82, before hunk 2): shift +2 (hunk 1 removed 2,
+ added 4).
+- Line 128 (inside hunk 2's removed range): no post-change counterpart — excluded from this
+ comparison, covered by (c) via its replacement lines 130-135.
+- Lines >= 129 (after both hunks): shift +7 (hunk 1 net +2, hunk 2 net +5: removed 1, added 6).
+
+| Baseline line | Baseline hits | Mapped post-change line | Post-change hits |
+|---|---|---|---|
+| 12 | 1 | 12 | 1 |
+| 14 | 1 | 14 | 1 |
+| 15 | 1 | 15 | 1 |
+| 16 | 1 | 16 | 1 |
+| 17 | 1 | 17 | 1 |
+| 18 | 1 | 18 | 1 |
+| 19 | 1 | 19 | 1 |
+| 20 | 1 | 20 | 1 |
+| 21 | 1 | 21 | 1 |
+| 22 | 1 | 22 | 1 |
+| 23 | 1 | 23 | 1 |
+| 24 | 1 | 24 | 1 |
+| 25 | 1 | 25 | 1 |
+| 26 | 1 | 26 | 1 |
+| 27 | 1 | 27 | 1 |
+| 32 | 1 | 32 | 1 |
+| 33 | 1 | 33 | 1 |
+| 39 | 1 | 39 | 1 |
+| 40 | 1 | 40 | 1 |
+| 46 | 1 | 46 | 1 |
+| 47 | 1 | 47 | 1 |
+| 50 | 1 | 50 | 1 |
+| 53 | 1 | 53 | 1 |
+| 54 | 1 | 54 | 1 |
+| 107 | 1 | 109 | 1 |
+| 110 | 1 | 112 | 1 |
+| 111 | 1 | 113 | 1 |
+| 112 | 1 | 114 | 1 |
+| 115 | 1 | 117 | 1 |
+| 116 | 1 | 118 | 1 |
+| 117 | 1 | 119 | 1 |
+| 118 | 1 | 120 | 1 |
+| 119 | 1 | 121 | 1 |
+| 120 | 1 | 122 | 1 |
+| 121 | 1 | 123 | 1 |
+| 122 | 1 | 124 | 1 |
+| 125 | 1 | 127 | 1 |
+| 126 | 1 | 128 | 1 |
+| 127 | 1 | 129 | 1 |
+| 128 | 1 | (removed hunk range — no counterpart; covered by (c)) | n/a |
+| 129 | 1 | 136 | 1 |
+| 131 | 1 | 138 | 1 |
+| 132 | 1 | 139 | 1 |
+| 133 | 1 | 140 | 1 |
+| 134 | 1 | 141 | 1 |
+| 135 | 1 | 142 | 1 |
+| 136 | 1 | 143 | 1 |
+| 137 | 1 | 144 | 1 |
+| 138 | 1 | 145 | 1 |
+| 139 | 1 | 146 | 1 |
+| 140 | 1 | 147 | 1 |
+| 141 | 1 | 148 | 1 |
+| 142 | 1 | 149 | 1 |
+| 143 | 1 | 150 | 1 |
+| 144 | 1 | 151 | 1 |
+| 145 | 1 | 152 | 1 |
+| 150 | 1 | 157 | 1 |
+| 151 | 1 | 158 | 1 |
+| 157 | 1 | 164 | 1 |
+| 158 | 1 | 165 | 1 |
+
+Every mapped line retains hits >= 1; no baseline line with hits >= 1 maps to a post-change hits
+of 0. No regression on changed lines.
+
+## Companion git-status porcelain listing
+
+```
+ M docs/features/active/kastringasync-keyequals-contains-offset-583/plan.2026-09-12T10-25.md
+?? docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/coverage-postchange.md
+?? docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/kbdactions-postchange.md
+```
+
+No untracked file escapes the anchored diff's tracked-file scope: the only tracked change is
+the plan file itself (this task's own check-off work in progress), and the two untracked paths
+are this phase's own evidence artifacts, both within this feature's evidence subtree.
+
+## Output Summary
+
+Root line-rate rose by +0.0001204691221254 (0.4298999577286177 -> 0.4300204268507431); root
+branch-rate unchanged (0.24095967959333). KaStringAsync.cs covered/total: 60/60 baseline, 65/65
+post-change (both 100%). Changed-line coverage: 6/6 = 100% over a 6-line denominator (added
+lines 130-135), with lines 81-84 (P3-T2 doc-comment reword) correctly excluded as
+non-executable. Projection comparison: every one of the 59 comparable baseline lines (excluding
+line 128, inside the removed hunk range and covered by (c)) maps to a post-change line with an
+identical hits value of 1 — no regression on any previously-covered line. Companion
+git-status porcelain listing confirms no untracked path outside this feature's evidence
+subtree.
diff --git a/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/coverage-postchange.md b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/coverage-postchange.md
new file mode 100644
index 000000000..293574f9b
--- /dev/null
+++ b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/coverage-postchange.md
@@ -0,0 +1,125 @@
+# Coverage Post-Change (P5-T5)
+
+- Timestamp: 2026-09-13T02-05
+- Command: collect --output
+ --output-format cobertura --settings --
+ QuickFiler.Test\bin\Debug\QuickFiler.Test.dll /InIsolation
+ /Settings:scripts\vscode\TaskMaster.cli.runsettings
+ "/TestCaseFilter:TestCategory!=LiveOutlook"
+- EXIT_CODE: 0
+
+## Deviation note
+
+Identical dotnet-coverage module-exclude settings file as used at P0-T8 was supplied, for the
+same pre-existing Deedle/FSharp instrumentation reason recorded there.
+
+## Test run result
+
+```
+Test Run Successful.
+Total tests: 1394
+ Passed: 1394
+ Total time: 14.4300 Seconds
+```
+
+(1394 total vs. the P0-T8 baseline's 1393 — the one added test is the new
+KeyEquals_ContainsMatchAtNonPrefixIndex_InvokesUpdateWithLastMatchedCharacter regression method.)
+
+## Root coverage figures
+
+- Root line-rate: 0.4300204268507431
+- Root branch-rate: 0.24095967959333
+
+## KaStringAsync.cs covered/total (post-change)
+
+- Covered: 65
+- Total: 65
+
+## Per-line hits projection
+
+| Line | Hits |
+|---|---|
+| 12 | 1 |
+| 14 | 1 |
+| 15 | 1 |
+| 16 | 1 |
+| 17 | 1 |
+| 18 | 1 |
+| 19 | 1 |
+| 20 | 1 |
+| 21 | 1 |
+| 22 | 1 |
+| 23 | 1 |
+| 24 | 1 |
+| 25 | 1 |
+| 26 | 1 |
+| 27 | 1 |
+| 32 | 1 |
+| 33 | 1 |
+| 39 | 1 |
+| 40 | 1 |
+| 46 | 1 |
+| 47 | 1 |
+| 50 | 1 |
+| 53 | 1 |
+| 54 | 1 |
+| 109 | 1 |
+| 112 | 1 |
+| 113 | 1 |
+| 114 | 1 |
+| 117 | 1 |
+| 118 | 1 |
+| 119 | 1 |
+| 120 | 1 |
+| 121 | 1 |
+| 122 | 1 |
+| 123 | 1 |
+| 124 | 1 |
+| 127 | 1 |
+| 128 | 1 |
+| 129 | 1 |
+| 130 | 1 |
+| 131 | 1 |
+| 132 | 1 |
+| 133 | 1 |
+| 134 | 1 |
+| 135 | 1 |
+| 136 | 1 |
+| 138 | 1 |
+| 139 | 1 |
+| 140 | 1 |
+| 141 | 1 |
+| 142 | 1 |
+| 143 | 1 |
+| 144 | 1 |
+| 145 | 1 |
+| 146 | 1 |
+| 147 | 1 |
+| 148 | 1 |
+| 149 | 1 |
+| 150 | 1 |
+| 151 | 1 |
+| 152 | 1 |
+| 157 | 1 |
+| 158 | 1 |
+| 164 | 1 |
+| 165 | 1 |
+
+## Raw output disposition
+
+- Raw-output location (relative to the per-user temp directory root): p583-p5t5/coverage.cobertura.xml
+- Prefix comparison (raw-output full path begins with repository root full path): False
+- Post-deletion existence check of the raw file: False (absent)
+- Directory listing of evidence/qa-gates/ after deletion (entry names only):
+ - csharpier-check.md
+ - csharpier-format.md
+ - msbuild-analyzers.md
+ - msbuild-nullable.md
+
+## Output Summary
+
+Exit code 0; inner test run "Test Run Successful." with 0 Failed (1394/1394 Passed); root
+line-rate 0.4300204268507431, root branch-rate 0.24095967959333; KaStringAsync.cs covered/total
+65/65; per-line hits projection holds 65 rows, no repeated line number; raw output confirmed
+outside the repository root and deleted; evidence/qa-gates/ directory listing contains no .xml
+entry.
diff --git a/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/csharpier-check.md b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/csharpier-check.md
new file mode 100644
index 000000000..3e2a8225e
--- /dev/null
+++ b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/csharpier-check.md
@@ -0,0 +1,20 @@
+# CSharpier Check (P5-T2)
+
+- Timestamp: 2026-09-13T01-45
+- Command: tool run csharpier check . (run from repository root,
+ read-only, repository-wide)
+- EXIT_CODE: 0
+
+## Verbatim output
+
+```
+Checked 1624 files in 5552ms.
+```
+
+## Output Summary
+
+Exit code 0; only the "Checked N files in T ms." summary line appears, with no per-file line
+ahead of it — 0 files needing formatting. Matches the recorded repo-wide clean shape ("Checked
+1517 files in 6574ms.", 0 needing formatting) at
+docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/qa-gates/csharpier-check.2026-08-22T09-53.md;
+the higher file count reflects the larger repository tree at this later point in time.
diff --git a/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/csharpier-format.md b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/csharpier-format.md
new file mode 100644
index 000000000..23ec5b309
--- /dev/null
+++ b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/csharpier-format.md
@@ -0,0 +1,28 @@
+# CSharpier Format (P5-T1)
+
+- Timestamp: 2026-09-13T01-40
+- Command: tool run csharpier format
+ QuickFiler/Controllers/KaStringAsync.cs
+ QuickFiler.Test/Controllers/KaStringAsyncTests.cs (run from repository root)
+- EXIT_CODE: 0
+
+## Verbatim output
+
+```
+Formatted 2 files in 2558ms.
+```
+
+## SHA-256 hashes (before / after)
+
+| File | SHA-256 before | SHA-256 after |
+|---|---|---|
+| QuickFiler/Controllers/KaStringAsync.cs | B404554D23D3C13F215BE28A1A782086AB19C14D9978F87BF46A3CB87052801C | B404554D23D3C13F215BE28A1A782086AB19C14D9978F87BF46A3CB87052801C |
+| QuickFiler.Test/Controllers/KaStringAsyncTests.cs | E5083D897D448343D2F977C0EFBC486E146B406EB0EF190AC7ED7DD81814D4EB | E5083D897D448343D2F977C0EFBC486E146B406EB0EF190AC7ED7DD81814D4EB |
+
+## Output Summary
+
+Exit code 0; processed count 2 files ("Formatted 2 files in 2558ms."); measured rewrite count
+0 (both files' SHA-256 hashes are identical before and after), distinct from the processed
+count, per the same processed-versus-rewritten distinction recorded at
+docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/qa-gates/csharpier-format.2026-08-22T09-52.md.
+Measured rewrite count is 0, so P5-T15 does not restart this phase from P5-T1.
diff --git a/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/final-qc-pass-attestation.md b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/final-qc-pass-attestation.md
new file mode 100644
index 000000000..03e094075
--- /dev/null
+++ b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/final-qc-pass-attestation.md
@@ -0,0 +1,90 @@
+# Final QC Pass Attestation (P5-T15)
+
+- Timestamp: 2026-09-13T02-30
+
+## Per-task recorded results, P5-T1 through P5-T7
+
+| Task | Gate | EXIT_CODE | Measured rewrite count (P5-T1 only) |
+|---|---|---|---|
+| P5-T1 | CSharpier format (scoped) | 0 | 0 |
+| P5-T2 | CSharpier check (repo-wide) | 0 | n/a |
+| P5-T3 | Analyzer Rebuild | 0 | n/a |
+| P5-T4 | Nullable Rebuild | 0 | n/a |
+| P5-T5 | Coverage-instrumented capture | 0 | n/a |
+| P5-T6 | KbdActionsTests re-run | 0 | n/a |
+| P5-T7 | Coverage delta computation | n/a (artifact-derived computation; no command exit code) | n/a |
+
+All recorded exit codes are 0; P5-T1's measured SHA-256 rewrite count is 0. No restart of
+Phase 5 from P5-T1 was triggered.
+
+## Anchored name-status diff (origin/main)
+
+Command: git diff --name-status origin/main
+
+```
+M QuickFiler.Test/Controllers/KaStringAsyncTests.cs
+M QuickFiler/Controllers/KaStringAsync.cs
+A docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/coverage-baseline.md
+A docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/coverage-tool-probe.md
+A docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/csharpier-check.md
+A docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/dotnet-bootstrap.md
+A docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/kbdactions-baseline.md
+A docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/msbuild-analyzers.md
+A docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/msbuild-nullable.md
+A docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/nuget-restore.md
+A docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/phase0-instructions-read.md
+A docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/csharpier-check.md
+A docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/csharpier-format.md
+A docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/msbuild-analyzers.md
+A docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/msbuild-nullable.md
+A docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/regression-testing/green-after-fix.md
+A docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/regression-testing/red-before-fix.md
+A docs/features/active/kastringasync-keyequals-contains-offset-583/issue.md
+A docs/features/active/kastringasync-keyequals-contains-offset-583/plan.2026-09-12T10-25.md
+A docs/features/active/kastringasync-keyequals-contains-offset-583/research/2026-09-12T10-35-kastringasync-keyequals-contains-offset-research.md
+A docs/features/active/kastringasync-keyequals-contains-offset-583/spec.md
+```
+
+The feature folder's issue.md, spec.md, plan file and research record report as Added relative
+to origin/main because the worktree HEAD carries a pre-execution documentation commit that
+added all four on top of that ref; these four paths are in scope for this check and are not a
+reason to restart the phase, per this task's own text.
+
+## Companion git-status porcelain listing
+
+Command: git status --porcelain
+
+```
+ M docs/features/active/kastringasync-keyequals-contains-offset-583/plan.2026-09-12T10-25.md
+ M docs/features/active/kastringasync-keyequals-contains-offset-583/spec.md
+?? docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/ac-status-summary.md
+?? docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/coverage-delta.md
+?? docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/coverage-postchange.md
+?? docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/kbdactions-postchange.md
+```
+
+The `M` entries are this feature's own plan file and spec.md (task check-offs and AC
+check-offs, both entries in spec.md's Write Set). The four untracked paths are this phase's own
+evidence artifacts, all inside this feature's evidence subtree.
+
+## Scope confirmation
+
+Every path reported by the anchored name-status diff falls within one of: (a) the two
+production/test files QuickFiler/Controllers/KaStringAsync.cs and
+QuickFiler.Test/Controllers/KaStringAsyncTests.cs; (b) this feature folder's issue.md, spec.md,
+plan file, research record, and evidence subtree, all Write Set entries. No path falls outside
+that scope. The companion porcelain listing reports no untracked path outside that same scope.
+No agent-memory path appears in either listing at this point in execution.
+
+## Toolchain loop attestation
+
+The format-then-lint-then-type-check-then-test loop (P5-T1 CSharpier format, P5-T2 CSharpier
+check, P5-T3 analyzer Rebuild, P5-T4 nullable Rebuild, P5-T5/P5-T6/P5-T7 test-and-coverage
+gates) completed as one uninterrupted pass: every recorded exit code is 0, P5-T1's measured
+rewrite count is 0, and no step required a restart from P5-T1.
+
+## Output Summary
+
+All of P5-T1 through P5-T7 recorded exit code 0 (P5-T1's measured rewrite count 0); the
+anchored name-status diff and companion porcelain listing both fall entirely within the stated
+scope; the toolchain loop completed as one uninterrupted pass with no restart. Final QC PASS.
diff --git a/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/kbdactions-postchange.md b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/kbdactions-postchange.md
new file mode 100644
index 000000000..93b41845f
--- /dev/null
+++ b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/kbdactions-postchange.md
@@ -0,0 +1,29 @@
+# KbdActionsTests Post-Change (P5-T6)
+
+- Timestamp: 2026-09-13T02-10
+- Command: QuickFiler.Test\bin\Debug\QuickFiler.Test.dll
+ /InIsolation /Settings:scripts\vscode\TaskMaster.cli.runsettings
+ "/TestCaseFilter:FullyQualifiedName~KbdActionsTests"
+- EXIT_CODE: 0
+
+## Verbatim result
+
+```
+Test Run Successful.
+Total tests: 4
+ Passed: 4
+ Total time: 1.2500 Seconds
+```
+
+## Comparison against P0-T9 baseline
+
+| | Baseline (P0-T9) | Post-change (P5-T6) | Delta |
+|---|---|---|---|
+| Passed | 4 | 4 | 0 |
+| Failed | 0 | 0 | 0 |
+
+## Output Summary
+
+Exit code 0; verdict "Test Run Successful."; Passed count identical to the P0-T9 baseline (4),
+0 Failed, delta 0. This is the AC5 evidence, obtained solely from the test-run outcome and
+never from a diff of the pinned KbdActionsTests file.
diff --git a/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/msbuild-analyzers.md b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/msbuild-analyzers.md
new file mode 100644
index 000000000..bb7ce340c
--- /dev/null
+++ b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/msbuild-analyzers.md
@@ -0,0 +1,27 @@
+# MSBuild Analyzer Rebuild (Post-Change, P5-T3)
+
+- Timestamp: 2026-09-13T01-50
+- Command: MSBuild.exe (VS18) TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug
+ "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true
+ /flp:logfile=;verbosity=detailed
+- EXIT_CODE: 0
+
+## Verdict line
+
+```
+Build succeeded.
+ 0 Warning(s)
+ 0 Error(s)
+```
+
+## Log-line counts
+
+- "Skipping target ... CoreCompile" occurrences: 0
+- "CoreCompile:" occurrences: 192 (non-vacuous rebuild)
+
+## Output Summary
+
+Exit code 0; verdict "Build succeeded."; "Skipping target CoreCompile" count of exactly 0;
+"CoreCompile:" count of 192 (>= 9, non-vacuous); recorded warning count is 0, which is no
+greater than the P0-T6 baseline ceiling of 0. Matches the recorded shape at
+docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/baseline/msbuild-analyzers.2026-08-22T09-21.md.
diff --git a/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/msbuild-nullable.md b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/msbuild-nullable.md
new file mode 100644
index 000000000..6027f8339
--- /dev/null
+++ b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/msbuild-nullable.md
@@ -0,0 +1,27 @@
+# MSBuild Nullable Rebuild (Post-Change, P5-T4)
+
+- Timestamp: 2026-09-13T01-55
+- Command: MSBuild.exe (VS18) TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug
+ "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true
+ /flp:logfile=;verbosity=detailed
+- EXIT_CODE: 0
+
+## Verdict line
+
+```
+Build succeeded.
+ 0 Warning(s)
+ 0 Error(s)
+```
+
+## Log-line count
+
+- "Skipping target ... CoreCompile" occurrences: 0
+- "CoreCompile:" occurrences: 167 (non-vacuous rebuild)
+
+## Output Summary
+
+Exit code 0; verdict "Build succeeded."; "Skipping target CoreCompile" count of exactly 0.
+Matches the recorded shape at
+docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/baseline/msbuild-nullable.2026-08-22T09-23.md.
+No nullable opt-in property was passed.
diff --git a/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/regression-testing/green-after-fix.md b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/regression-testing/green-after-fix.md
new file mode 100644
index 000000000..8425cbd50
--- /dev/null
+++ b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/regression-testing/green-after-fix.md
@@ -0,0 +1,33 @@
+# Green After Fix (P4-T1)
+
+- Timestamp: 2026-09-13T01-30
+- Command (build): MSBuild.exe (VS18) TaskMaster.sln /t:Build /m /p:Configuration=Debug
+ "/p:Platform=Any CPU"
+- Command (test):
+ QuickFiler.Test\bin\Debug\QuickFiler.Test.dll /InIsolation
+ "/TestCaseFilter:FullyQualifiedName~KaStringAsyncTests"
+- EXIT_CODE: 0
+
+## Verbatim result
+
+```
+Test Run Successful.
+Total tests: 13
+ Passed: 13
+ Total time: 1.5036 Seconds
+```
+
+All 13 tests in KaStringAsyncTests passed, individually including:
+- KeyEquals_ContainsMatchAtNonPrefixIndex_InvokesUpdateWithLastMatchedCharacter (new AC3 test):
+ Passed
+- KeyEquals_ContainsMatchWhileActivated_InvokesUpdateAndReturnsTrue (reworded prefix-case
+ test, AC4): Passed
+
+## Output Summary
+
+Build (plain incremental) exit 0, compiling the P2-T1 production fix and the P3-T1/P3-T2
+prose rewords into both the QuickFiler and QuickFiler.Test output assemblies. Test run exit
+code 0; verdict "Test Run Successful."; 0 Failed among all 13 tests in KaStringAsyncTests; both
+the new regression method and the reworded prefix-case test are individually reported Passed —
+matching the passing-run shape recorded at
+docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/baseline/vstest-baseline.2026-08-22T09-30.md.
diff --git a/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/regression-testing/red-before-fix.md b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/regression-testing/red-before-fix.md
new file mode 100644
index 000000000..350e1692d
--- /dev/null
+++ b/docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/regression-testing/red-before-fix.md
@@ -0,0 +1,37 @@
+# Red Before Fix (P1-T2) [expect-fail]
+
+- Timestamp: 2026-09-13T01-15
+- Command (build): MSBuild.exe (VS18) TaskMaster.sln /t:Build /m /p:Configuration=Debug
+ "/p:Platform=Any CPU"
+- Command (test):
+ QuickFiler.Test\bin\Debug\QuickFiler.Test.dll /InIsolation
+ "/TestCaseFilter:FullyQualifiedName=QuickFiler.Controllers.Tests.KaStringAsyncTests.KeyEquals_ContainsMatchAtNonPrefixIndex_InvokesUpdateWithLastMatchedCharacter"
+- EXIT_CODE: 1
+- ExpectedExitCode: 1
+
+## Verbatim failure detail
+
+```
+Failed KeyEquals_ContainsMatchAtNonPrefixIndex_InvokesUpdateWithLastMatchedCharacter [142 ms]
+Error Message:
+ Expected updateArg to be "1" because Update receives the last character of the matched span
+(Key.IndexOf("1", StringComparison.Ordinal) + other.Length - 1 = 1), not the pre-fix
+prefix-only offset that yielded "0", but "0" differs near "0" (index 0).
+```
+
+```
+Total tests: 1
+ Failed: 1
+Test Run Failed.
+ Total time: 1.2507 Seconds
+```
+
+## Output Summary
+
+Build (plain incremental) succeeded, exit 0, compiling the P1-T1 test into the QuickFiler.Test
+build output assembly. The scoped vstest run against the new test method, run against
+unmodified production code (Phase 2 has not yet run), observed EXIT_CODE 1, matching the
+declared ExpectedExitCode of 1. Verdict "Test Run Failed."; exactly 1 test ran and it Failed;
+the captured assertion shows an observed value of "0" against an expected "1" — the same
+failing-run shape (exit code 1, ExpectedExitCode 1, "Test Run Failed.") recorded at
+docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/regression-testing/red-before-fix.2026-08-22T09-38.md.
diff --git a/docs/features/active/kastringasync-keyequals-contains-offset-583/issue.md b/docs/features/active/kastringasync-keyequals-contains-offset-583/issue.md
new file mode 100644
index 000000000..2b70f6838
--- /dev/null
+++ b/docs/features/active/kastringasync-keyequals-contains-offset-583/issue.md
@@ -0,0 +1,83 @@
+# KaStringAsync.KeyEquals branch 1 computes a prefix-only Substring offset under a Contains guard
+
+- Issue: #583
+- Type: bug
+- Work Mode: full-bug
+- Severity: Low (latent)
+- Owner: drmoisan
+- Last Updated: 2026-09-12T10-25
+- Source: https://github.com/drmoisan/TaskMaster/issues/583
+
+## Summary
+
+Branch 1 of `KaStringAsync.KeyEquals` guards on a substring test but computes its `Update`
+argument with prefix arithmetic. The two are only consistent when `other` is a prefix of `Key`.
+
+In `QuickFiler/Controllers/KaStringAsync.cs`:
+
+- The branch-1 guard is `Key.Contains(other)` - a substring test.
+- Its body computes `Update(Key.Substring(other.Length - 1, 1))`, an offset that is only
+ meaningful when `other` is a prefix of `Key`.
+
+For `Key = "abc"` and `other = "b"`, `Contains` is `true` and the expression yields `"a"`, which
+is neither the matched character nor the character following it.
+
+## Reachability
+
+Reachable in principle whenever the registered digit width is 2. `GenerateStringKbdAction` in
+`QuickFiler/Controllers/QfcCollectionController.cs` registers keys `"01"` through `"12"` at that
+width; typing `"1"` matches `"01"` at index 1, as a substring rather than as a prefix.
+
+It has no observable effect today, because `Update` is `null` on every `KaStringAsync` instance
+production creates:
+
+- `QfcCollectionController.cs` passes `null` for both `update` and `toggleControl`.
+- `KbdActions.Add(string, TKey, VDelegate)` builds its element with the parameterless
+ constructor, which assigns neither callback.
+
+So every `Update is not null` guard in `KeyEquals` evaluates `false` on every production
+evaluation. This is a latent defect, not a live one.
+
+## Maintainer Decision (recorded 2026-09-11)
+
+Resolution: Option 2 - keep the `Contains` guard and correct the offset arithmetic.
+
+Branch 1 of `KaStringAsync.KeyEquals` keeps its substring test. The `Update` argument is computed
+from the match position (`Key.IndexOf(other)`) so that `Update` receives the last matched
+character for every matching row, not only for prefix matches. For the two-digit registration
+`"01"` through `"12"`, typing `1` continues to match `01`, `10`, `11`, and `12`; the `01` row now
+yields `1` instead of `0`.
+
+Rationale: this is the only option with no user-visible change to keyboard filtering, and
+`Update` is null on every production instance today, so the correction carries no runtime risk.
+
+Binding constraints on the implementation:
+
+- Do not substitute `StartsWith` for `Contains`.
+- Do not change the branch-1 guard.
+- The pinned test FilterKeys_WhenDistinctStoredKeysCoexist_PreservesKeyboardMatchingSemantics in
+ the QuickFiler.Test KbdActionsTests file must continue to pass unchanged, and that file must
+ not be edited.
+
+## Regression Case
+
+`Key` equal to `"01"`, `other` equal to `"1"`, with a non-null `Update`, must yield `"1"`.
+Today it yields `"0"`.
+
+## Acceptance Criteria
+
+- [ ] AC1: The recorded decision (corrected offset arithmetic under the retained `Contains`
+ guard) is reflected in the implementation; the branch-1 guard text is unchanged and no
+ `StartsWith` call is introduced in `QuickFiler/Controllers/KaStringAsync.cs`.
+- [ ] AC2: `KaStringAsync.KeyEquals` branch 1 derives its `Update` argument from the match
+ position via `Key.IndexOf(other)`, so the character passed to `Update` is the last character
+ of the matched span for a non-prefix match as well as for a prefix match.
+- [ ] AC3: A regression test in `QuickFiler.Test/Controllers/KaStringAsyncTests.cs` covers the
+ two-digit-width non-prefix case (`Key` equal to `"01"`, `other` equal to `"1"`) with a non-null
+ `Update` and asserts the received argument is `"1"`.
+- [ ] AC4: The pre-existing prefix-case behaviour is preserved: for `Key` equal to `"abc"` and
+ `other` equal to `"ab"`, `Update` still receives `"b"`.
+- [ ] AC5: The pinned keyboard-matching test in the QuickFiler.Test KbdActionsTests file passes
+ unchanged, and that file is not modified by this change.
+- [ ] AC6: The full C# toolchain passes in order: CSharpier check, the analyzer rebuild, the
+ nullable rebuild, and the MSTest run.
diff --git a/docs/features/active/kastringasync-keyequals-contains-offset-583/plan.2026-09-12T10-25.md b/docs/features/active/kastringasync-keyequals-contains-offset-583/plan.2026-09-12T10-25.md
new file mode 100644
index 000000000..7d66c4a9e
--- /dev/null
+++ b/docs/features/active/kastringasync-keyequals-contains-offset-583/plan.2026-09-12T10-25.md
@@ -0,0 +1,543 @@
+# kastringasync-keyequals-contains-offset (Plan)
+
+- **Issue:** #583
+- **Parent (optional):** none
+- **Owner:** drmoisan
+- **Last Updated:** 2026-09-12T10-25
+- **Status:** Draft
+- **Version:** 1.0
+- **Work Mode:** full-bug (per issue.md). Acceptance-criteria source is spec.md's Acceptance Criteria
+ section only, per the acceptance-criteria-tracking skill's full-bug rule. user-story.md is
+ intentionally absent; its absence is not a defect for this work mode.
+
+## Task-count ledger (mechanically counted, per phase)
+
+| Phase | Tasks |
+|---|---|
+| 0 | 9 (P0-T1..P0-T9) |
+| 1 | 2 (P1-T1..P1-T2) |
+| 2 | 1 (P2-T1) |
+| 3 | 2 (P3-T1..P3-T2) |
+| 4 | 1 (P4-T1) |
+| 5 | 15 (P5-T1..P5-T15) |
+| **Total** | **30** |
+
+## Write-set discipline
+
+An automated blast-radius extractor harvests every whitespace-free backticked token from this
+plan and treats it as a claim that the diff writes that path, with no notion of polarity. To keep
+this plan's backticked write claims from exceeding spec.md's Write Set, this plan reserves
+backtick spans exclusively for the following repository-relative paths, all of which are entries
+in that Write Set: issue.md, spec.md, this plan file, the research artifact, the two
+production/test source files QuickFiler/Controllers/KaStringAsync.cs and
+QuickFiler.Test/Controllers/KaStringAsyncTests.cs (written with their full directory-qualified
+paths, never as a bare filename), and the four evidence subfolders under this feature's evidence
+directory.
+
+Every other command, flag, tool name, build output, settings file, identifier, field label, and
+context path in this plan is written as plain text with no backticks, including command-line
+switches (for example the vstest TestCaseFilter and InIsolation switches), MSBuild target and
+property names, the git ref used to anchor diffs, bare source-file names with no directory
+qualifier, and every path cited only for context. This applies in particular to: coverage.config,
+dotnet-tools.json, the .dotnet-sdk per-worktree SDK junction, the packages directory, TaskMaster.sln,
+scripts/vscode/Install-RepoDotNetSdk.ps1, scripts/vscode/TaskMaster.cli.runsettings, the
+QuickFiler.Test build output assembly, the collection controller QfcCollectionController.cs, the
+keyboard dispatch handler KeyboardHandler.cs, the QuickFiler.Test Controllers KbdActions test
+file, and every path cited under the archived feature folder for issue #445
+(docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445) as the source
+of recorded gate output. None of these is created, modified, or deleted by this change. This
+change's own source and documentation writes are the backtick-marked Write Set paths and no
+others. The agent-memory tree is the one further path this branch carries: the agents executing
+this plan maintain it as tracked session bookkeeping, it is no part of this change's footprint,
+and P5-T15 admits it to that task's scope list on that basis.
+
+The transient raw coverage output produced by P0-T8 and P5-T5 is likewise plain text here and is
+never a write claim against this repository. Each of those tasks directs dotnet-coverage to write
+its Cobertura-format file into a session-specific folder beneath the operating system's per-user
+temporary directory, resolved at run time from the runtime's temporary-path lookup and lying
+outside the repository root; the file is read for its figures and deleted before the task that
+produced it completes. This change creates no raw coverage XML anywhere inside the repository, and
+no raw coverage document and no test-results file is added to git. Per the projection-only
+evidence decision recorded on issue #671 on 2026-09-11, the markdown artifacts named in P0-T8 and
+P5-T5 are the evidence of record.
+
+## Pinned file (read-only, never edited by this plan)
+
+The QuickFiler.Test Controllers KbdActions test file — the file housing the test method named
+for preserving keyboard-matching semantics across distinct stored keys — is pinned per the
+maintainer decision in issue.md. No task in this plan edits it. Its unaffected status is asserted
+only through test-run outcomes (P0-T9 baseline, P5-T6 post-change), never through a diff of that
+file.
+
+## Command Reference (applies to every task below that names one of these commands)
+
+- **pwsh invocation.** Every command in this plan that is not a bare git invocation is run through
+ pwsh -NoProfile -Command, with the outer quoting as single quotes around the whole payload,
+ because the calling shell expands a dollar sign inside double quotes and would corrupt any
+ PowerShell-side variable reference in the payload.
+- **dotnet.exe resolution.** Resolve the pinned .NET SDK via the per-worktree .dotnet-sdk junction
+ (running its dotnet.exe with the version switch) first; if that path does not resolve, run
+ scripts/vscode/Install-RepoDotNetSdk.ps1 as the fallback provisioner (P0-T2). Every later dotnet
+ invocation in this plan uses the resolved absolute path, referred to below as the resolved
+ dotnet executable.
+- **CSharpier invocation.** CSharpier is invoked only as the resolved dotnet executable's "tool run
+ csharpier format" or "tool run csharpier check" subcommand, so the dotnet-tools.json
+ manifest-pinned version 1.2.6 is used, never a global install. CSharpier 1.2.6 requires an
+ explicit subcommand; a bare csharpier invocation with no subcommand does not run.
+- **MSBuild / vstest resolution.** Neither msbuild nor vstest.console.exe is on PATH in this
+ environment. Resolve each via vswhere.exe at the standard Visual Studio Installer path, using the
+ latest-version, all-products, find-by-relative-tool-path form, and invoke the resolved absolute
+ path, referred to below as the resolved vstest executable.
+- **Rebuild, never a plain build, for the analyzer and nullable gates.** MSBuild's incremental
+ up-to-date check does not invalidate on a command-line property change, so a warm plain-build
+ invocation would return exit 0 with compilation skipped on every project and run no analyzers,
+ making the gate incapable of failing. Every analyzer-rebuild and nullable-rebuild task in this
+ plan therefore issues the solution's Rebuild target and records a non-vacuity proof (a zero count
+ of compile-skip log lines in a detailed file log). A plain incremental build target is used only
+ in P1-T2 and P4-T1: at P1-T2 its purpose is solely to compile the P1-T1 test-file edit into the
+ QuickFiler.Test build output assembly; at P4-T1 its purpose is to compile the P2-T1 production
+ fix and the P3-T1/P3-T2 test-file rewords into both the QuickFiler and QuickFiler.Test build
+ output assemblies. Neither invocation gates on analyzer or nullable behavior.
+- **No solution-wide nullable opt-in property anywhere.** No project in this repository carries a
+ Nullable element; that MSBuild property is a solution-wide opt-in that CI deliberately omits. No
+ task in this plan adds it.
+- **git diff anchoring.** Every git diff in this plan is anchored to the origin main ref, this
+ worktree's base, never left unanchored and never pinned to a literal commit SHA. The worktree HEAD
+ is not identical to that ref: it carries one pre-execution documentation commit that added this
+ feature folder's issue.md, spec.md, plan file and research record, so an unscoped anchored diff
+ reports those four paths in addition to whatever this plan's tasks change. P5-T7's anchored diff is
+ scoped to QuickFiler/Controllers/KaStringAsync.cs, which that commit did not touch, so its added
+ line set is unaffected; P5-T15's scope list names the four documentation paths explicitly.
+
+### Phase 0 — Context, Policy Reads, Toolchain Bootstrap, and Baselines
+
+- [x] [P0-T1] Read, in order, CLAUDE.md, .claude/rules/general-code-change.md,
+ .claude/rules/general-unit-test.md, .claude/rules/quality-tiers.md, .claude/rules/tonality.md,
+ and .claude/rules/csharp.md, plus
+ docs/features/active/kastringasync-keyequals-contains-offset-583/issue.md,
+ docs/features/active/kastringasync-keyequals-contains-offset-583/spec.md, and
+ docs/features/active/kastringasync-keyequals-contains-offset-583/research/2026-09-12T10-35-kastringasync-keyequals-contains-offset-research.md
+ end to end, then author
+ `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/phase0-instructions-read.md`
+ with a Timestamp field, a Policy Order field (the six-file order above), and the explicit list
+ of every file read, including the two source files QuickFiler/Controllers/KaStringAsync.cs and
+ QuickFiler.Test/Controllers/KaStringAsyncTests.cs (cited here as plain context, not as write
+ targets of this task).
+ Acceptance: the artifact exists and contains all three required fields with the ordered file
+ list; no policy file is modified.
+
+- [x] [P0-T2] Resolve the pinned .NET SDK for this worktree (attempt the .dotnet-sdk junction's
+ dotnet.exe with the version switch; if that path does not resolve, run
+ scripts/vscode/Install-RepoDotNetSdk.ps1 as the fallback provisioner), then run the resolved
+ dotnet executable's tool-restore command from the repository root to restore the CSharpier 1.2.6
+ tool pinned by dotnet-tools.json, recording both steps in
+ `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/dotnet-bootstrap.md`.
+ Acceptance: an observed exit code of 0 for the tool-restore invocation, with output naming
+ csharpier version 1.2.6, matching the recorded success shape ("Tool 'csharpier' (version
+ '1.2.6') was restored... Restore was successful.") at
+ docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/baseline/dotnet-tool-restore.2026-08-22T09-18.md.
+
+- [x] [P0-T3] Run a solution-wide NuGet restore of TaskMaster.sln from the repository root to
+ populate this worktree's packages directory, then verify the restored package-directory count
+ is at least 100, recording both steps in
+ `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/nuget-restore.md`.
+ Acceptance: an observed exit code of 0 for the restore command and a recorded package-directory
+ count of at least 100, consistent in kind with the recorded precedent (265 directories, floor
+ 150) at
+ docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/baseline/nuget-restore.2026-08-22T09-18.md.
+
+- [x] [P0-T4] Probe global-tool availability of the dotnet-coverage tool (a Get-Command lookup
+ followed by its version switch); if absent, install it as a global dotnet tool via the resolved
+ dotnet executable. Record the result in
+ `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/coverage-tool-probe.md`.
+ Acceptance: the artifact records the tool as present, with a resolved path, a version string, and
+ an observed exit code of 0, matching the recorded shape at
+ docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/baseline/coverage-tool-probe.2026-08-22T09-32.md.
+
+- [x] [P0-T5] Run the resolved dotnet executable's CSharpier check subcommand against the whole
+ repository root (read-only) and record a Timestamp field, a Command field, an EXIT_CODE field,
+ and an Output Summary field stating the observed files-checked count and files-needing-formatting
+ count in
+ `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/csharpier-check.md`.
+ Acceptance: an observed exit code of 0 and a summary line of the form "Checked N files in T ms."
+ with 0 files needing formatting, matching the recorded clean-tree shape ("Checked 1517 files in
+ 6621ms.", exit code 0) at
+ docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/baseline/csharpier-check.2026-08-22T09-19.md.
+
+- [x] [P0-T6] Run the solution's analyzer Rebuild (Configuration Debug, Platform Any CPU, the
+ EnableNETAnalyzers and EnforceCodeStyleInBuild properties both true) with a detailed file logger,
+ then count the "Skipping target CoreCompile" and "CoreCompile:" log-line occurrences, and count
+ the total warning lines. Record a Timestamp field, a Command field, an EXIT_CODE field, and an
+ Output Summary field stating the observed warning count in
+ `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/msbuild-analyzers.md`.
+ Acceptance: an observed exit code of 0, a verdict line reading "Build succeeded.", a
+ "Skipping target CoreCompile" count of exactly 0, a "CoreCompile:" count of at least 9
+ (proving the rebuild was not vacuous), and a recorded numeric warning count, which is the
+ ceiling P5-T3 compares against, matching the recorded shape (0 skip / 96 CoreCompile / 5
+ pre-existing System.Reactive packages.config warnings / 0 errors) at
+ docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/baseline/msbuild-analyzers.2026-08-22T09-21.md.
+
+- [x] [P0-T7] Run the solution's nullable Rebuild (Configuration Debug, Platform Any CPU, the
+ TreatWarningsAsErrors property true, no nullable opt-in property) with a detailed file logger,
+ then count the "Skipping target CoreCompile" log-line occurrences. Record a Timestamp field, a
+ Command field, an EXIT_CODE field, and an Output Summary field in
+ `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/msbuild-nullable.md`.
+ Acceptance: an observed exit code of 0, a verdict line reading "Build succeeded.", and a
+ "Skipping target CoreCompile" count of exactly 0, matching the recorded shape (0 skip / 130
+ CoreCompile / 5 pre-existing warnings / 0 errors) at
+ docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/baseline/msbuild-nullable.2026-08-22T09-23.md.
+
+- [x] [P0-T8] Run a coverage-instrumented test capture of the whole QuickFiler.Test.dll assembly,
+ using dotnet-coverage collect wrapping the resolved vstest executable against that assembly with
+ the TaskMaster CLI runsettings file, InIsolation, and the LiveOutlook-category exclusion filter.
+ The coverage tool still writes its Cobertura-format output, but to a transient file in a
+ session-specific folder beneath the operating system's per-user temporary directory, resolved at
+ run time from the runtime's temporary-path lookup, never to any path inside this repository.
+ That raw file is read for its figures within this task and then deleted at the end of this task;
+ it is never added to git, and no raw coverage document is written under this feature's evidence
+ directory. From the raw document, read the root line-rate and branch-rate attributes; aggregate
+ every class element whose filename ends with the KaStringAsync source file name (deduplicated by
+ line number, maximum hits) into a covered/total count for that file; and derive from the same
+ aggregation a per-line hits projection for that file, one row per executable line number paired
+ with its maximum hit count across every matching class element, deduplicated by line number.
+ Record the numeric root line-rate, the numeric root branch-rate, the covered/total count, and the
+ per-line hits projection (under a section heading reading Per-line hits projection) in
+ `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/coverage-baseline.md`,
+ together with: the raw-output location expressed as its subfolder and file name relative to the
+ temporary directory root (no absolute host path); the run-time result of a prefix comparison
+ showing that the resolved temporary directory's full path does not begin with the repository
+ root's full path, recorded as that comparison's boolean verdict alone with neither operand path
+ reproduced; the post-deletion existence check of the raw file; and a directory listing of
+ `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/`
+ taken after the deletion, recorded as entry names only with no containing-directory header line.
+ None of those four recorded observations reproduces an absolute path: the raw-output location is
+ recorded relative to the temporary directory root, the prefix comparison as a labelled boolean
+ with neither operand reproduced, the existence check as a boolean, and the directory listing as
+ entry names only. That constraint governs those four observations only. The artifact's mandatory
+ Command field records the exact command, which names the resolved absolute executable paths per
+ the Command Reference above, and lies outside the constraint's scope; the archived precedent this
+ task is compared against records its command in the same form.
+ Acceptance: an observed exit code of 0; the inner test run reports "Test Run Successful." with 0
+ Failed; the Output Summary field carries the numeric line-rate, branch-rate, and the
+ KaStringAsync file's covered/total counts (not a placeholder), using the same per-file
+ aggregation method recorded at
+ docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/baseline/coverage-baseline.2026-08-22T09-34.md;
+ the artifact's per-line hits projection section holds at least one row and repeats no line
+ number; the recorded directory listing of the baseline evidence subfolder contains no entry
+ whose name ends with .xml; the recorded prefix comparison shows the raw-output location lies
+ outside this repository's root; the recorded post-deletion existence check reports the raw
+ file absent; and none of the four observations above reproduces an absolute path — the
+ raw-output location appearing relative to the temporary directory root, the prefix comparison as
+ a labelled boolean with neither operand path, the existence check as a boolean, and the directory
+ listing as entry names only.
+
+- [x] [P0-T9] Run a scoped, non-instrumented vstest filter selecting every test whose fully
+ qualified name contains the pinned KbdActions test class name, against the QuickFiler.Test build
+ output assembly, with InIsolation and the TaskMaster CLI runsettings file, to record the pinned
+ KbdActions test file's baseline Passed/Failed count without ever diffing that file. Record a
+ Timestamp field, a Command field, an EXIT_CODE field, and an Output Summary field in
+ `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/kbdactions-baseline.md`.
+ Acceptance: an observed exit code of 0, a verdict line reading "Test Run Successful.", and a
+ recorded Passed count with 0 Failed for that class, in the same per-class reporting shape (class
+ name, with total, passed, failed and exit columns) recorded at
+ docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/baseline/vstest-baseline.2026-08-22T09-30.md;
+ that archived record's own count, three of three on 2026-08-22, predates one test method since
+ added to the pinned KbdActions test file, so this task's recorded count is expected to differ
+ from that archived figure and is not compared against it.
+
+### Phase 1 — Regression Test First (Red Before Fix)
+
+- [x] [P1-T1] Add a new test method named
+ KeyEquals_ContainsMatchAtNonPrefixIndex_InvokesUpdateWithLastMatchedCharacter to
+ `QuickFiler.Test/Controllers/KaStringAsyncTests.cs`, using the file's existing NewKa factory
+ helper, MSTest attributes, a FluentAssertions assertion with a because-style string, and
+ Arrange/Act/Assert section comments preceded by an Intent comment block naming issue #583 and
+ both the pre-fix ("0") and post-fix ("1") results: construct with Key equal to "01", a non-null
+ Update callback capturing its argument, Activated set to true, call KeyEquals with the argument
+ "1", and assert the captured argument equals "1".
+ Acceptance: the method exists in the file with exactly this Key/other/Activated arrangement and
+ an assertion of the literal "1"; the file's pre-existing test methods are otherwise unchanged.
+
+- [x] [P1-T2] [expect-fail] Build TaskMaster.sln with a plain incremental build (Configuration Debug, Platform
+ Any CPU) to compile the P1-T1 test into the QuickFiler.Test build output assembly, then run the
+ resolved vstest executable against that assembly with InIsolation and a test-case filter
+ selecting only the new test method by its exact fully qualified name, against unmodified
+ production code (Phase 2 has not yet run). Record the failing run, with an ExpectedExitCode
+ field declared as 1 so the evidence collector normalizes the row rather than reading a genuine
+ failure, in
+ `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/regression-testing/red-before-fix.md`.
+ Acceptance: the observed EXIT_CODE is 1, matching the declared ExpectedExitCode of 1; the verdict
+ line reads "Test Run Failed."; exactly 1 test ran and it Failed, with the captured assertion
+ showing an observed value of "0" against an expected "1" — the same failing-run shape (exit code
+ 1, ExpectedExitCode 1, "Test Run Failed.") recorded at
+ docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/regression-testing/red-before-fix.2026-08-22T09-38.md.
+
+### Phase 2 — Minimal Production Fix
+
+- [x] [P2-T1] In `QuickFiler/Controllers/KaStringAsync.cs`, replace the branch-1 Update argument
+ expression, currently reading Key.Substring(other.Length - 1, 1) at line 128, with
+ Key.Substring(Key.IndexOf(other, StringComparison.Ordinal) + other.Length - 1, 1), leaving the
+ Key.Contains(other) guard at line 125 and every other line of the method unchanged, and
+ introducing no StartsWith call anywhere in the file.
+ Acceptance: the file contains the new expression at that call site; the literal text
+ "Substring(other.Length - 1, 1)" no longer appears anywhere in the file; the guard text
+ "Key.Contains(other)" is present and unchanged; the file contains no occurrence of the text
+ "StartsWith(".
+
+### Phase 3 — Prose Rewords
+
+- [x] [P3-T1] In `QuickFiler.Test/Controllers/KaStringAsyncTests.cs`, reword the because-string in
+ the test named for a contains-match while activated that invokes Update and returns true, from
+ "Update receives Key.Substring(other.Length - 1, 1) => index 1 => \"b\"" to "Update receives the
+ last character of the matched span (Key.IndexOf(\"ab\", StringComparison.Ordinal) + other.Length
+ - 1 = 1) => \"b\"", without changing the asserted value "b".
+ Acceptance: the old because-string no longer appears in the file; the new because-string appears
+ verbatim; the test's assertion of the value "b" is unchanged.
+
+- [x] [P3-T2] In `QuickFiler/Controllers/KaStringAsync.cs`, reword the doc-comment sentence in the
+ "Argument contract" paragraph, currently at lines 80-82, from "The guard clause at the top of
+ this method rejects both fail-fast, so branch 1's substring offset expression is never evaluated
+ with a negative start index." to "The guard clause at the top of this method rejects both
+ fail-fast, so branch 1's derived offset (Key.IndexOf(other) plus the matched length) is never
+ evaluated with a negative start index: IndexOf is non-negative because branch 1 only runs when
+ Contains already matched, and other.Length is at least 1 because of the guard above."
+ Acceptance: the old sentence no longer appears in the file; the new sentence appears verbatim
+ inside the XML doc comment; no other sentence in the doc comment is changed.
+
+### Phase 4 — Green-After-Fix Verification
+
+- [x] [P4-T1] Build TaskMaster.sln with a plain incremental build (Configuration Debug, Platform
+ Any CPU) to compile the P2-T1 fix and the P3-T1/P3-T2 rewords into the QuickFiler.Test build
+ output assembly and QuickFiler's own output assembly, then run the resolved vstest executable
+ against the QuickFiler.Test build output assembly with InIsolation and a test-case filter
+ selecting every test whose fully qualified name contains the KaStringAsyncTests class name.
+ Record the passing run in
+ `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/regression-testing/green-after-fix.md`.
+ Acceptance: an observed exit code of 0; the verdict line reads "Test Run Successful."; 0 Failed
+ among every test in that class; the new method
+ KeyEquals_ContainsMatchAtNonPrefixIndex_InvokesUpdateWithLastMatchedCharacter and the test named
+ for a contains-match while activated that invokes Update and returns true are both individually
+ reported Passed — the same passing-run shape ("Test Run Successful.", 0 Failed, exit code 0)
+ recorded at
+ docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/baseline/vstest-baseline.2026-08-22T09-30.md.
+
+### Phase 5 — Final Quality-Assurance Loop
+
+- [x] [P5-T1] Run the resolved dotnet executable's CSharpier format subcommand from the repository
+ root, scoped only to the two files this change edits (QuickFiler/Controllers/KaStringAsync.cs
+ and `QuickFiler.Test/Controllers/KaStringAsyncTests.cs`), then take a SHA-256 hash of each file
+ immediately before and immediately after the invocation to measure the actual rewrite count
+ (distinct from CSharpier's own processed-file count). Record a Timestamp field, a Command field,
+ an EXIT_CODE field, and an Output Summary field (stating both the processed count and the
+ measured rewrite count) in
+ `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/csharpier-format.md`.
+ Acceptance: an observed exit code of 0; the summary states a processed count of 2 files, distinct
+ from the SHA-256-measured rewrite count, per the same processed-versus-rewritten distinction
+ recorded at
+ docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/qa-gates/csharpier-format.2026-08-22T09-52.md;
+ if the measured rewrite count is greater than 0, P5-T15 restarts this phase from P5-T1.
+
+- [x] [P5-T2] Run the resolved dotnet executable's CSharpier check subcommand from the repository
+ root (read-only, repository-wide) and record a Timestamp field, a Command field, an EXIT_CODE
+ field, and an Output Summary field in
+ `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/csharpier-check.md`.
+ Acceptance: an observed exit code of 0 and a summary reporting 0 files needing formatting (only
+ the "Checked N files in T ms." summary line appears, with no per-file line ahead of it), matching
+ the recorded repo-wide clean shape ("Checked 1517 files in 6574ms.", 0 needing formatting) at
+ docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/qa-gates/csharpier-check.2026-08-22T09-53.md.
+
+- [x] [P5-T3] Run the solution's analyzer Rebuild (Configuration Debug, Platform Any CPU, the
+ EnableNETAnalyzers and EnforceCodeStyleInBuild properties both true) with a detailed file logger,
+ count the "Skipping target CoreCompile" and "CoreCompile:" log-line occurrences, and record a
+ Timestamp field, a Command field, an EXIT_CODE field, and an Output Summary field in
+ `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/msbuild-analyzers.md`.
+ Acceptance: an observed exit code of 0, a verdict of "Build succeeded.", a "Skipping target
+ CoreCompile" count of exactly 0, a "CoreCompile:" count of at least 9, and a warning count no
+ greater than the P0-T6 baseline ceiling, matching the recorded shape at
+ docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/baseline/msbuild-analyzers.2026-08-22T09-21.md.
+
+- [x] [P5-T4] Run the solution's nullable Rebuild (Configuration Debug, Platform Any CPU, the
+ TreatWarningsAsErrors property true, no nullable opt-in property) with a detailed file logger,
+ count the "Skipping target CoreCompile" log-line occurrences, and record a Timestamp field, a
+ Command field, an EXIT_CODE field, and an Output Summary field in
+ `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/msbuild-nullable.md`.
+ Acceptance: an observed exit code of 0, a verdict of "Build succeeded.", and a "Skipping target
+ CoreCompile" count of exactly 0, matching the recorded shape at
+ docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/baseline/msbuild-nullable.2026-08-22T09-23.md.
+
+- [x] [P5-T5] Run a coverage-instrumented test capture of the whole QuickFiler.Test.dll assembly,
+ identical in form to P0-T8. The coverage tool still writes its Cobertura-format output, but to a
+ transient file in a session-specific folder beneath the operating system's per-user temporary
+ directory, resolved at run time from the runtime's temporary-path lookup, never to any path
+ inside this repository. That raw file is read for its figures within this task and then deleted
+ at the end of this task; it is never added to git, and no raw coverage document is written under
+ this feature's evidence directory. From the raw document, read the root line-rate and
+ branch-rate attributes; aggregate every class element whose filename ends with the KaStringAsync
+ source file name (deduplicated by line number, maximum hits) into a post-change covered/total
+ count for that file; and derive from the same aggregation the per-line hits projection for that
+ file in the same form as P0-T8 (one row per executable line number paired with its maximum hit
+ count across every matching class element, deduplicated by line number), which P5-T7's
+ changed-line intersection reads. Record the numeric root line-rate, the numeric root
+ branch-rate, the post-change covered/total count, and the per-line hits projection (under a
+ section heading reading Per-line hits projection) in
+ `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/coverage-postchange.md`,
+ together with: the raw-output location expressed as its subfolder and file name relative to the
+ temporary directory root (no absolute host path); the run-time result of a prefix comparison
+ showing that the resolved temporary directory's full path does not begin with the repository
+ root's full path, recorded as that comparison's boolean verdict alone with neither operand path
+ reproduced; the post-deletion existence check of the raw file; and a directory listing of
+ `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/`
+ taken after the deletion, recorded as entry names only with no containing-directory header line.
+ None of those four recorded observations reproduces an absolute path: the raw-output location is
+ recorded relative to the temporary directory root, the prefix comparison as a labelled boolean
+ with neither operand reproduced, the existence check as a boolean, and the directory listing as
+ entry names only. That constraint governs those four observations only. The artifact's mandatory
+ Command field records the exact command, which names the resolved absolute executable paths per
+ the Command Reference above, and lies outside the constraint's scope; the archived precedent this
+ task is compared against records its command in the same form.
+ Acceptance: an observed exit code of 0; the inner test run reports "Test Run Successful." with 0
+ Failed; the Output Summary field carries the numeric line-rate, branch-rate, and the
+ KaStringAsync file's covered/total counts (not a placeholder), using the same per-file
+ aggregation method recorded at
+ docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/qa-gates/coverage-postchange.2026-08-22T10-38.md;
+ the artifact's per-line hits projection section holds at least one row and repeats no line
+ number; the recorded directory listing of the qa-gates evidence subfolder contains no entry
+ whose name ends with .xml; the recorded prefix comparison shows the raw-output location lies
+ outside this repository's root; the recorded post-deletion existence check reports the raw
+ file absent; and none of the four observations above reproduces an absolute path — the
+ raw-output location appearing relative to the temporary directory root, the prefix comparison as
+ a labelled boolean with neither operand path, the existence check as a boolean, and the directory
+ listing as entry names only.
+
+- [x] [P5-T6] Run the same scoped, non-instrumented vstest filter used at P0-T9 (selecting every
+ test whose fully qualified name contains the pinned KbdActions test class name, against the
+ QuickFiler.Test build output assembly, with InIsolation and the TaskMaster CLI runsettings file)
+ after the fix, and compare its Passed count against the P0-T9 baseline, without ever diffing the
+ pinned file. Record a Timestamp field, a Command field, an EXIT_CODE field, and an Output Summary
+ field (stating both counts and their delta) in
+ `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/kbdactions-postchange.md`.
+ Acceptance: an observed exit code of 0, a verdict of "Test Run Successful.", and a Passed count
+ identical to the P0-T9 baseline with 0 Failed and a delta of 0 — this is the AC5 evidence,
+ obtained solely from the test-run outcome and never from a diff of the pinned file.
+
+- [x] [P5-T7] Compute the coverage delta from the two markdown evidence artifacts
+ `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/coverage-baseline.md`
+ (P0-T8) and
+ `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/coverage-postchange.md`
+ (P5-T5), reading no raw coverage document (both raw outputs were deleted by the tasks that
+ produced them): (a) the instrumented-run line-rate/branch-rate delta, from the numeric root
+ line-rate and branch-rate figures recorded in the two artifacts; (b) the KaStringAsync file's
+ per-file covered/total before and after, from the covered/total counts recorded in the two
+ artifacts; and (c) changed-line coverage, by intersecting the added line numbers from a git diff
+ of QuickFiler/Controllers/KaStringAsync.cs anchored to the origin main ref, using a zero-context
+ unified diff, against the per-line hits projection recorded in coverage-postchange.md, whose rows
+ are that file's executable lines only: the denominator of the changed-line percentage is the set
+ of added line numbers that appear as a row in that projection, and the added line numbers with no
+ row there are non-executable and are listed separately as excluded from both the numerator and the
+ denominator. The reworded doc-comment lines from P3-T2 are the expected members of that excluded
+ set. A continuation line introduced by a formatter wrap is classified by the same row test rather
+ than assumed to be absent from the projection: the recorded evidence at
+ docs/features/active/2026-08-26-efc-unguarded-archive-root-read-crashes-ui-thread-638/evidence/qa-gates/p7-t2-coverage-changed-lines.md
+ shows each continuation line of a wrapped call carrying its own row, so such a line belongs in the
+ denominator whenever it appears as a row there. Also
+ compare the per-line hits projection recorded in coverage-baseline.md against the one recorded
+ in coverage-postchange.md: map each baseline line number to its post-change line number through
+ the same anchored zero-context diff (a baseline line outside every hunk shifts by the net
+ added-minus-removed line count of the hunks that precede it; a baseline line inside a removed
+ hunk range has no post-change counterpart, is excluded from this comparison, and is covered by
+ (c) through its replacement lines), then pair each mapped line's baseline hits with its
+ post-change hits. Record all three computations, plus the projection comparison, plus a
+ companion git-status porcelain listing (to confirm no untracked file escapes the anchored
+ diff's tracked-file scope), in
+ `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/coverage-delta.md`.
+ Acceptance: the artifact records numeric baseline coverage, numeric post-change coverage, and a
+ numeric changed-line coverage percentage over the denominator stated above; that denominator
+ holds at least one line; every added line of
+ QuickFiler/Controllers/KaStringAsync.cs that appears as a row in the coverage-postchange.md
+ projection shows hits of at least 1 there, and the artifact lists the added lines excluded as
+ non-executable; and no line with hits of at least 1 in the coverage-baseline.md projection has
+ hits of 0 at its mapped line in the coverage-postchange.md projection, following the changed-line
+ intersection methodology recorded at
+ docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/qa-gates/coverage-delta.2026-08-22T10-40.md
+ with the per-line hit source being the recorded projections rather than a raw document.
+
+- [x] [P5-T8] Check off AC1 in
+ `docs/features/active/kastringasync-keyequals-contains-offset-583/spec.md`'s Acceptance Criteria
+ section ("The recorded maintainer decision ... is reflected in the implementation ...") by
+ changing its unchecked box to a checked box, citing P2-T1's diff (guard text unchanged, no
+ StartsWith introduced) as the verifying evidence.
+ Acceptance: only that one checkbox line changes in the file; no criterion text is altered.
+
+- [x] [P5-T9] Check off AC2 in
+ `docs/features/active/kastringasync-keyequals-contains-offset-583/spec.md`'s Acceptance Criteria
+ section ("KaStringAsync's KeyEquals branch-one derives its Update argument from the match
+ position via Key.IndexOf(other) ...") by changing its unchecked box to a checked box, citing
+ P2-T1's diff and P4-T1's green run as the verifying evidence.
+ Acceptance: only that one checkbox line changes in the file; no criterion text is altered.
+
+- [x] [P5-T10] Check off AC3 in
+ `docs/features/active/kastringasync-keyequals-contains-offset-583/spec.md`'s Acceptance Criteria
+ section ("A regression test in QuickFiler.Test/Controllers/KaStringAsyncTests.cs covers the
+ two-digit-width non-prefix case ...") by changing its unchecked box to a checked box, citing
+ P1-T1 (test added) and P4-T1 (passes post-fix) as the verifying evidence.
+ Acceptance: only that one checkbox line changes in the file; no criterion text is altered.
+
+- [x] [P5-T11] Check off AC4 in
+ `docs/features/active/kastringasync-keyequals-contains-offset-583/spec.md`'s Acceptance Criteria
+ section ("The pre-existing prefix-case behavior is preserved ...") by changing its unchecked box
+ to a checked box, citing P3-T1's reword (asserted value unchanged) and P4-T1's green run as the
+ verifying evidence.
+ Acceptance: only that one checkbox line changes in the file; no criterion text is altered.
+
+- [x] [P5-T12] Check off AC5 in
+ `docs/features/active/kastringasync-keyequals-contains-offset-583/spec.md`'s Acceptance Criteria
+ section ("The pinned keyboard-matching test in the QuickFiler.Test KbdActionsTests file passes
+ unchanged, and that file is not modified ...") by changing its unchecked box to a checked box,
+ citing P0-T9 and P5-T6's matching Passed counts (0 delta) as the verifying evidence.
+ Acceptance: only that one checkbox line changes in the file; no criterion text is altered.
+
+- [x] [P5-T13] Check off AC6 in
+ `docs/features/active/kastringasync-keyequals-contains-offset-583/spec.md`'s Acceptance Criteria
+ section ("The full C# toolchain passes in order ...") by changing its unchecked box to a checked
+ box, citing P5-T1 through P5-T7's four gate artifacts as the verifying evidence.
+ Acceptance: only that one checkbox line changes in the file; no criterion text is altered.
+
+- [x] [P5-T14] Verify all six acceptance criteria in
+ `docs/features/active/kastringasync-keyequals-contains-offset-583/spec.md` are checked, and
+ report the AC Status Summary (Source, Total AC items, Checked off, Remaining, Items remaining)
+ per the acceptance-criteria-tracking skill in
+ `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/ac-status-summary.md`.
+ Acceptance: the artifact reports Total AC items 6, Checked off 6, Remaining 0.
+
+- [x] [P5-T15] Attest, in
+ `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/final-qc-pass-attestation.md`,
+ that P5-T1 through P5-T7 each recorded an exit code of 0 (or, for P5-T1, a measured rewrite count
+ of 0), by running an anchored name-status git diff against the origin main ref together with a
+ companion git-status porcelain listing (per the paired-diff convention: the anchored diff
+ enumerates committed tracked changes, the porcelain listing catches anything uncommitted or
+ untracked) and confirming every path reported by the anchored name-status diff, which enumerates
+ tracked changes only, falls only within: the two production/test files
+ QuickFiler/Controllers/KaStringAsync.cs and `QuickFiler.Test/Controllers/KaStringAsyncTests.cs`;
+ this feature folder, comprising its issue.md, its spec.md, this plan file, its research record and
+ its evidence subtree, every one of which is an entry in spec.md's Write Set; and the agent-memory
+ tree, which the agents executing this plan write as tracked session bookkeeping and which is no
+ part of this change's source or documentation footprint. The anchored diff is expected to report
+ this feature folder's issue.md, spec.md, plan file and research record as added relative to the
+ origin main ref, because the worktree HEAD carries a pre-execution documentation commit that added
+ all four on top of that ref; those four paths are in scope for this check and are not a reason to
+ restart the phase. The companion git-status porcelain listing is expected to
+ report the evidence artifacts this phase creates, which git reports as one collapsed entry per
+ wholly-untracked directory rather than one line per file; that entry lies within the evidence
+ subtree named above, and the porcelain listing exists to catch a change the anchored diff
+ cannot see rather than to be scope-checked as a tracked change. If any of P5-T1 through P5-T7
+ recorded a non-zero exit code (excluding a declared ExpectedExitCode normalization), or
+ P5-T1's measured rewrite count was greater than 0, or the anchored diff shows a tracked file
+ outside that scope, or the porcelain listing reports an untracked path outside that scope, this
+ task instructs restarting Phase 5 from P5-T1, per the general-code-change.md restart rule.
+ Acceptance: the artifact states, for each of P5-T1 through P5-T7, its recorded exit code and,
+ for P5-T1, its measured rewrite count; lists the paired diff/status output; confirms every
+ path reported by the anchored diff falls within the stated scope and that the porcelain
+ listing reports no untracked path outside it; and confirms the
+ format-then-lint-then-type-check-then-test loop completed as one uninterrupted pass, in the
+ same attestation shape recorded at
+ docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/evidence/qa-gates/final-qc-pass-attestation.2026-08-22T10-42.md.
diff --git a/docs/features/active/kastringasync-keyequals-contains-offset-583/research/2026-09-12T10-35-kastringasync-keyequals-contains-offset-research.md b/docs/features/active/kastringasync-keyequals-contains-offset-583/research/2026-09-12T10-35-kastringasync-keyequals-contains-offset-research.md
new file mode 100644
index 000000000..fc3668310
--- /dev/null
+++ b/docs/features/active/kastringasync-keyequals-contains-offset-583/research/2026-09-12T10-35-kastringasync-keyequals-contains-offset-research.md
@@ -0,0 +1,177 @@
+# Research: KaStringAsync.KeyEquals branch-1 offset correction (Issue #583)
+
+- Timestamp: 2026-09-12T10-35
+- Scope: preparation-only research. No production source edited, no build or test run performed.
+
+## R1 — Current branch-1 expression and surrounding structure
+
+File: `QuickFiler/Controllers/KaStringAsync.cs`.
+
+`KeyEquals(string other)` spans lines 106-145. The guard clauses (null at 110-113, empty at
+115-123) run first. The branch-1 block is lines 125-130:
+
+```csharp
+125 if (Key.Contains(other))
+126 {
+127 if (Activated && Update is not null)
+128 Update(Key.Substring(other.Length - 1, 1));
+129 return true;
+130 }
+```
+
+Branches 2 and 3 follow as `else if (other.Length == 1)` (131-135, `ToggleControl`) and
+`else if (other.Length > 1)` (136-142, `Update(Key.Substring(0, 1))` then `ToggleControl`), with
+the shared `Activated = false; return false;` trailer at 143-144. Only the branch-1 offset
+(line 128) is in scope per the maintainer decision; branch 3's `Key.Substring(0, 1)` (line 139)
+is untouched by Option 2 and is a separate, already-correct expression (first character of the
+key, unconditional on match position).
+
+## R2 — Comparison semantics (Contains vs IndexOf) and analyzer exposure
+
+**Target framework.** `QuickFiler/QuickFiler.csproj` line 13: `v4.8.1` — legacy non-SDK-style .NET Framework 4.8.1 project (packages.config, explicit `` items).
+
+**Ordinal-vs-culture asymmetry.** `string.Contains(string)` performs an ordinal (culture-insensitive) search in both .NET Framework and .NET; `string.IndexOf(string)` with no `StringComparison` argument performs a culture-sensitive search using the current culture in .NET Framework. This is the exact asymmetry CA1307 ("Specify StringComparison for clarity") and CA1310 ("Specify StringComparison for correctness") exist to flag: CA1310 is the one Microsoft's guidance associates with `string.IndexOf(string)`, because that overload's default behavior is culture-dependent and can differ from an ordinal caller's expectation; CA1307 is the milder, clarity-only counterpart typically associated with methods such as `Contains`/`Equals` whose behavior does not actually vary by culture.
+
+**Can they disagree for this input domain?** No evidence of disagreement was found or is expected for this domain. The registered keys are two-digit numeral strings (`"01"`–`"12"`, confirmed at `QfcCollectionController.cs:1109-1112`, `key = (i + 1).ToString("00")`) and the probes are the accumulated single/short ASCII-digit filter string built in `KeyboardHandler.cs:180` (`_filterBuilder.Append(char.ToLower((char)e.KeyValue))`). ASCII digit characters (`0`–`9`) have no culture-specific collation, casing, or normalization differences under any standard .NET culture, so an ordinal search and a current-culture search over this restricted alphabet locate the same substring at the same index. This is a characterization from documented framework behavior over the enumerated input alphabet, not a runtime observation (no code was executed for this research).
+
+**Analyzer configuration search.** `.editorconfig` (repo root) was searched in full for `CA1307`/`CA1310`: no explicit entries exist. The file instead carries a single blanket default at line 27:
+
+```
+dotnet_analyzer_diagnostic.severity = suggestion
+```
+
+with the comment (lines 23-26) stating this default exists specifically "so [new analyzer diagnostics] cannot be promoted to errors under the nullable `/p:TreatWarningsAsErrors=true` build." Every explicit override in the file (Meziantou `MA####`, Roslynator `RCS####`, Sonar `S####`, `CRR0029`) is also pinned to `suggestion`; the only diagnostic pinned above suggestion is `MSTEST0032` at `warning` (line 29). No `.globalconfig` file exists anywhere in the repository (glob returned no matches). `QuickFiler.csproj`'s `` items (lines 594-604) are Meziantou, Roslynator, AsyncFixer, BannedApiAnalyzers, and SonarAnalyzer — none of these ship CA1307/CA1310 (those are built-in Roslyn/.NET-analyzer IDs, gated by the project's `EnableNETAnalyzers=true` MSBuild property used in the approved analyzer-rebuild command, not by a NuGet `` reference).
+
+**Conclusion.** Because `dotnet_analyzer_diagnostic.severity = suggestion` is a blanket, source-agnostic default with no CA1307/CA1310-specific override anywhere in the repository, even if CA1310 fires on `Key.IndexOf(other)` (no `StringComparison`), it would report at `suggestion` severity. Suggestion-severity diagnostics are not warnings and are not promoted to errors by either the analyzer rebuild (`/p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true`, no `TreatWarningsAsErrors`) or the nullable rebuild (`/p:TreatWarningsAsErrors=true`, which only escalates *warnings*). An explicit `StringComparison.Ordinal` argument on `IndexOf` is therefore **not required** for either rebuild to pass. It is **advisable**: it makes the offset derivation's comparison semantics textually consistent with the ordinal `Contains` guard it depends on, and it preempts the (currently dormant) CA1307/CA1310 diagnostics if their severities are ever tightened. Adding `StringComparison.Ordinal` would **not itself introduce a diagnostic** — it is precisely the fix pattern CA1307/CA1310 recommend.
+
+## R3 — Callers of KeyEquals and producers of a non-null Update
+
+**`KeyEquals` overloads/implementers** (`IKbdAction.KeyEquals`, `IKbdAction.cs:14`): four sibling implementations exist — `KaChar.cs:42,77`, `KaKey.cs:43,78`, and the one under study, `KaStringAsync.cs:106`. Only the `string`-keyed `KaStringAsync` implementation contains the Contains/Substring branch-1 logic in question.
+
+**Callers, production (`QuickFiler/Controllers/KbdActions.cs`):** every call to `x.KeyEquals(key)` is inside the generic `KbdActions` class: `ContainsKey` (85), `FilterKeys` (87), `Find` (91), `FindIndex` (109, 116). `KeyboardHandler.cs:181,188` calls `StringActionsAsync.ContainsKey(...)` and `StringActionsAsync.FilterKeys(...)` on the live keyboard-dispatch path (`StringActionsAsync` typed as `KbdActions>`, declared at `IQfcKeyboardHandler.cs:26` and `KeyboardHandler.cs:84`). This is the only production call chain that reaches `KaStringAsync.KeyEquals`.
+
+**Production construction sites for `KaStringAsync`:** exactly one, `QfcCollectionController.GenerateStringKbdAction` (`QfcCollectionController.cs:1114-1121`):
+
+```csharp
+1114 var stringAsyncAction = new KaStringAsync(
+1115 "Collection",
+1116 key,
+1117 (s) => ChangeByIndexAsync(int.Parse(s) - 1),
+1118 //(s) => grp.ItemViewer.LblItemNumber.Text = s,
+1119 null,
+1120 null
+1121 );
+```
+
+Both `update` (position 4) and `toggleControl` (position 5) are the literal `null` at 1119-1120; the intended `Update` callback is present only as a comment (line 1118), never wired. The only other production path that could construct a `KaStringAsync` is `KbdActions<...>.Add(string, TKey, VDelegate)` (`KbdActions.cs:126-140`), which builds its element with `new()` — the parameterless constructor (`KaStringAsync.cs:12`) — and assigns only `SourceId`, `Key`, `Delegate` (`KbdActions.cs:135-138`); it never touches `Update`/`ToggleControl`, so both remain their default `null`. No other production call site constructs a `KaStringAsync`.
+
+**Conclusion:** the issue's claim is confirmed — `Update` is `null` on every `KaStringAsync` instance production code creates, via both the direct constructor call and the generic `Add(string, TKey, VDelegate)` path. Test code is the only place a non-null `Update` is ever supplied (`KaStringAsyncTests.cs`'s `NewKa` helper, line 26, and inline test-local lambdas).
+
+## R4 — Existing tests constraining branch-1 behaviour
+
+File: `QuickFiler.Test/Controllers/KaStringAsyncTests.cs`.
+
+| Test method | Inputs | Asserted value | Stays TRUE under Option 2? |
+|---|---|---|---|
+| `KeyEquals_ContainsMatchWhileActivated_InvokesUpdateAndReturnsTrue` (78-97) | `Key="abc"`, `other="ab"`, `Activated=true` | `updateArg == "b"`, with because-string `"Update receives Key.Substring(other.Length - 1, 1) => index 1 => \"b\""` (line 90-92) | **Value stays TRUE** (see arithmetic below). **Because-string becomes stale** — it quotes the exact old expression, which is no longer what the code executes. |
+| `KeyEquals_ContainsMatchWhileNotActivated_ReturnsTrueWithoutUpdate` (100-115) | `Key="abc"`, `other="ab"`, `Activated=false` | `updateCalled == false` | Unaffected — gated on `Activated`, never reaches the offset expression. |
+| `KeyEquals_SingleCharNonMatchWhileActivated_...` (118-132) | `Key="abc"`, `other="z"` | branch 2 (`ToggleControl`) | Unaffected — branch 2, no offset expression involved. |
+| `KeyEquals_MultiCharNonMatchWhileActivated_InvokesUpdateWithFirstCharAndReturnsFalse` (135-153) | `Key="abc"`, `other="zz"` | `updateArg == "a"` (branch 3, `Key.Substring(0,1)`) | Unaffected — branch 3's expression is untouched by Option 2. |
+| `KeyEquals_NullDelegatesAreToleratedInNonMatchBranches` (156-167) | `Key="abc"`, `other="zz"`, null delegates | no throw | Unaffected — branch 3. |
+| `KeyEquals_MultiCharNonMatchWhileNotActivated_...` (170-188) | `Key="abc"`, `other="zz"`, `Activated=false` | `updates` empty | Unaffected — branch 3, gated on `Activated`. |
+| `KeyEquals_LatchSurvivesMatchThenNonMatchTransition_StillResetsToFirstChar` (191-214) — **the matching-then-non-matching probe test** | `Key="abc"`; `KeyEquals("ab")` then `KeyEquals("zz")` | `updates.Should().Equal(new[] { "b", "a" })` | **Stays TRUE.** First call is branch 1 with `other="ab"` (prefix match, arithmetic unchanged — see below); second call is branch 3, untouched. No stale literal in this test's prose. |
+| `KeyEquals_EmptyProbe_ThrowsArgumentExceptionNamingOther` (217-256) | `""`, both an inactive and an active+non-null-Update instance | `ArgumentException` named `"other"` | Unaffected — the empty-string guard clause (lines 115-123) runs before any `Contains`/offset code in both the old and new implementation. |
+| `KeyEquals_NullProbe_ThrowsArgumentNullExceptionNamingOther` (259-277) | `null` | `ArgumentNullException` named `"other"` | Unaffected — null guard runs first, same in both versions. |
+
+**Arithmetic — the only test with a stale literal, `KeyEquals_ContainsMatchWhileActivated_...`:**
+`Key="abc"`, `other="ab"`. `Key.Contains("ab")` is `true` under both the current and the corrected code (the guard is unchanged). `other` is a prefix of `Key` here (`IndexOf("ab") == 0`).
+- Current: `Key.Substring(other.Length - 1, 1)` = `Substring(1, 1)` = `"b"`.
+- Corrected: last character of the matched span = `Key.IndexOf(other) + other.Length - 1` = `0 + 2 - 1 = 1`; `Key.Substring(1, 1)` = `"b"`.
+Both formulas agree because this probe happens to be a prefix match — this is exactly why AC4 (below) pins the prefix case as unaffected, and exactly why the because-string is stale prose rather than a wrong assertion: the *value* `"b"` is still correct, but the *reason given* (a literal quote of the soon-to-be-replaced expression) will no longer describe the code. Recommended reword: replace the quoted literal with a description, e.g. `"Update receives the last character of the matched span (IndexOf(\"ab\") + other.Length - 1 = 1) => \"b\""`.
+
+No other test in the file quotes the literal expression `Key.Substring(other.Length - 1, 1)` in an assertion or a because-string (a targeted grep of the file for that exact literal found it only in the production source at line 128, not in any test assertion string; the test's because-string quotes it as descriptive prose, which is the one occurrence requiring rewording).
+
+## R5 — The pinned test (`KbdActionsTests.cs`)
+
+File: `QuickFiler.Test/Controllers/KbdActionsTests.cs` (not to be edited; read-only for this research).
+
+- `EnumerableConstructor_WhenStoredKeysDifferButKeyEqualsOverlaps_DoesNotThrow` (57-84): seeds two `KaStringAsync` instances via object initializers (`Key="10"`, `Key="1"`), setting only `SourceId`, `Key`, `Delegate` — **`Update` and `ToggleControl` are never set, so they are `null`** (default field values, `KaStringAsync.cs:147-159`). It asserts only that construction does not throw; it never calls `KeyEquals` directly and never observes `Update`.
+- `FilterKeys_WhenDistinctStoredKeysCoexist_PreservesKeyboardMatchingSemantics` (87-123): builds its `KbdActions` via `actions.Add("Collection", "10", _ => Task.CompletedTask)` and `actions.Add("Collection", "1", _ => Task.CompletedTask)` — the `Add(string, TKey, VDelegate)` overload (`KbdActions.cs:126-140`), which (per R3) never assigns `Update`/`ToggleControl`, leaving them `null`. It asserts only on `KeyEquals`'s **return value**, indirectly through `FilterKeys`/`ContainsKey`/the indexer (`actions["10"].Should().NotBeNull()`), never on any `Update` side effect.
+
+**Conclusion:** Option 2 changes only the *argument* passed to `Update` in branch 1 (and only when `Update` is non-null and `Activated`); it does not change `KeyEquals`'s boolean return value, because the guard (`Key.Contains(other)`) is explicitly unchanged per the maintainer decision. Both tests in this file supply a `null` `Update` and assert only on return-value-derived behavior (membership, filtering, indexing). Neither test can be affected by the Option 2 fix. This file does not need to be, and per the maintainer decision must not be, edited.
+
+## R6 — Stale documentation
+
+**`KaStringAsync.KeyEquals` XML doc comment** (`KaStringAsync.cs:57-105`): searched in full for any literal reproduction of `Key.Substring(other.Length - 1, 1)` or an equivalent offset formula — none exists. The only related sentence is the paraphrase at lines 80-83 (`Argument contract.` paragraph):
+
+> "...The guard clause at the top of this method rejects both fail-fast, so branch 1's substring offset expression is never evaluated with a negative start index."
+
+This sentence remains **true** after the fix (the empty-probe guard still runs before any offset arithmetic), but its implicit justification narrows: today "non-negative" follows solely from `other.Length >= 1` (guard) feeding `other.Length - 1 >= 0`. Under the corrected expression (`Key.IndexOf(other) + other.Length - 1`), non-negativity additionally depends on `Key.IndexOf(other) >= 0`, which holds only because branch 1 is entered exclusively when `Key.Contains(other)` is already `true`. The sentence is not factually wrong, but it should be reworded to name both preconditions so a future reader does not assume the old single-guard justification still fully explains it. Suggested replacement: "...so branch 1's derived offset (`Key.IndexOf(other)` plus the matched length) is never evaluated with a negative start index: `IndexOf` is non-negative because branch 1 only runs when `Contains` already matched, and `other.Length` is at least 1 because of the guard above." No other sentence in the doc comment (the `` latch-contract paragraphs, the ``, or the `` blocks) references the offset arithmetic; all remain accurate because the `Contains` guard and the `KeyEquals` return contract are explicitly unchanged.
+
+**`QfcCollectionController.cs`:** searched around `GenerateStringKbdAction` (1101-1123) and its caller `RegisterNavigationAsyncAction` (1091-1099) for any comment stating a prefix assumption for the two-digit keys — none exists. The commented-out line 1118 (`//(s) => grp.ItemViewer.LblItemNumber.Text = s,`) documents the intended (never-wired) `Update` callback, not a prefix assumption, and needs no change.
+
+## R7 — Retention gates from Issue #445
+
+Searched the full repository (via `git grep`-equivalent full-text search, not limited to a subdirectory) for the literals `Key.Substring(other.Length - 1, 1)` and `Key.Contains(other)`. Matches:
+
+- `QuickFiler/Controllers/KaStringAsync.cs` — production source (the code under change).
+- `QuickFiler.Test/Controllers/KaStringAsyncTests.cs` — the because-string identified in R4.
+- `docs/features/active/kastringasync-keyequals-contains-offset-583/issue.md` — this issue's own record.
+- `docs/features/archive/2026-08-07-quickfiler-keyboard-action-contract-defects-445/{spec.md, issue.md, plan.2026-08-21T18-09.md, code-review.2026-08-22T11-30.md, feature-audit.2026-08-22T11-30.md, research/keyboard-action-contract-defects.2026-08-21T18-20.md, evidence/qa-gates/*.md, evidence/baseline/structural-counts.2026-08-22T09-36.md, evidence/issue-updates/followup-substring-defect.2026-08-22T10-46.md, evidence/other/ac-status-summary.2026-08-22T10-50.md}` — all under `docs/features/archive/...-445/`, i.e., #445's now-closed, archived feature folder.
+- `docs/features/potential/promoted/2026-08-07-quickfiler-keyboard-action-contract-defects.md` — the promoted-potential source document for #445.
+- `docs/features/active/2026-08-27-.../spec.md` and two other unrelated active-feature research documents that merely cite the expression in prose while discussing #445's history.
+- `.claude/agent-memory/atomic-executor/project_concurrent_dotnet_coverage_deadlock_and_doccomment_retention_gate.md` — an agent-memory note *describing* the #445 gate methodology (a lesson learned), not an executable gate.
+
+`docs/features/archive/2026-08-07-.../evidence/baseline/structural-counts.2026-08-22T09-36.md` shows the gate's actual mechanism: a one-time PowerShell command (`git grep -n -F 'TOKEN' -- 'PATHSPEC' | Measure-Object -Line`) run manually during #445's Phase-0 baselining and recorded as a static table in that dated evidence file. It was never wired into a script, hook, or CI workflow.
+
+Searched explicitly for a **live** enforcement point and found none:
+- `.github/` workflows: no match for either literal (grep against `.github` returned no files).
+- `.claude/hooks/**` (full listing enumerated): none of the 37 hook files reference either literal or a generic "literal register" / "structural count" gate mechanism by content (file listing inspected; none named or scoped to this feature).
+- No `scripts/` directory content matches either literal (the repo-wide search's only non-doc, non-source hits were the two `.cs` files already listed).
+
+**Conclusion (explicit negative result):** the #445 "structural-count gate" was a one-time, manually-run Phase-0 baseline captured as a dated Markdown evidence artifact inside #445's now-archived feature folder. It is **inert** — not a live script, hook, CI workflow, or test — and it exercised no ongoing enforcement mechanism that would break when `KaStringAsync.cs`'s branch-1 expression changes under #583. No live gate of any kind counts occurrences of `Key.Substring(other.Length - 1, 1)` or `Key.Contains(other)` in this repository today.
+
+## R8 — Test project conventions
+
+`QuickFiler.Test/Controllers/KaStringAsyncTests.cs` is the location AC3 targets; it lives at repo-relative path `QuickFiler.Test/Controllers/`, mirroring `QuickFiler/Controllers/`. Framework: MSTest (`Microsoft.VisualStudio.TestTools.UnitTesting`, `[TestClass]`/`[TestMethod]`). Assertion library: FluentAssertions (`.Should().Be(...)`, `.Should().ThrowExactly().WithParameterName(...)`), consistently supplying a `because:`-style trailing string literal as the human-readable rationale for each assertion. Conventions observed in the existing file to match for the new AC3 test:
+- A class-level `///` summary (lines 10-17) precedes `[TestClass]`.
+- Each `[TestMethod]` uses `// Arrange`, `// Act`, `// Assert` section comments (e.g., lines 79-96).
+- Regression-specific tests carry an `// Intent: ...` comment block above the AAA sections explaining the defect being pinned and the pre-fix behavior it replaces (e.g., lines 172-178, 191-197, 217-227, 258-266) — the AC3 test should follow this pattern, naming the issue/defect and stating both the pre-fix (`"0"`) and post-fix (`"1"`) result.
+- The shared `NewKa(...)` factory helper (lines 21-26) is the established construction seam and should be reused for the new AC3 test rather than calling the constructor directly.
+
+## R9 — Edge cases for the registered two-digit key width
+
+Registered keys (per `QfcCollectionController.GenerateStringKbdAction`, `key = (i + 1).ToString("00")`): `"01"` through `"12"`. For a single-character probe, a key of this format matches (`Contains`) if and only if the probe equals one of its two digits. Enumerating the distinct **match-position** shapes reachable, with the corrected expression `Key.IndexOf(other) + other.Length - 1` (here `other.Length == 1`, so this reduces to `Key.IndexOf(other)`, i.e., the position of the single matched digit) against the current (wrong) `Key.Substring(other.Length - 1, 1)` = `Substring(0, 1)` (always the first character, regardless of match position):
+
+| Key | Probe | Match position (`IndexOf`) | Corrected `Update` arg | Current (buggy) `Update` arg | Differ? |
+|---|---|---|---|---|---|
+| `"01"` | `"1"` | 1 (second char) | `"1"` | `"0"` | **Yes** — this is the issue's regression case. |
+| `"10"` | `"1"` | 0 (first char) | `"1"` | `"1"` | No — prefix match, both formulas agree. |
+| `"11"` | `"1"` | 0 (first occurrence; digit repeats) | `"1"` | `"1"` | No — `IndexOf` returns the first of the two occurrences (index 0), and the first-character formula happens to return the same character, so this key is not a distinguishing case even though the probe occurs twice. |
+| `"12"` | `"1"` | 0 (first char) | `"1"` | `"1"` | No — prefix match. |
+
+For `Key="11"`, `other="1"` specifically: `"1"` occurs at both index 0 and index 1. `string.IndexOf` returns the **first** match, index 0, so `Update` receives `Key[0] = "1"` — the same character branch 3's `Substring(0,1)` would also yield, and the same result the current (unfixed) code already produces for this key. This key does not exercise a behavior change; `"01"` is the only one of the four reachable two-digit keys where the corrected and current expressions diverge, which matches the issue's chosen regression case exactly.
+
+## Recommendation
+
+Implement Option 2 exactly as the maintainer recorded it:
+1. Leave the branch-1 guard (`Key.Contains(other)`) and the empty/null argument guards unchanged.
+2. Replace `Key.Substring(other.Length - 1, 1)` (line 128) with an offset derived from `Key.IndexOf(other)` — recommended form: `Key.Substring(Key.IndexOf(other) + other.Length - 1, 1)`, optionally with an explicit `StringComparison.Ordinal` on the `IndexOf` call for semantic consistency with `Contains` (advisable, not required by any active analyzer gate; see R2).
+3. Add the AC3 regression test to `KaStringAsyncTests.cs` following the file's existing `NewKa`/AAA/`because:` conventions (R8), covering `Key="01"`, `other="1"`, non-null `Update`, asserting `"1"`.
+4. Reword the one stale because-string identified in R4 (`KeyEquals_ContainsMatchWhileActivated_InvokesUpdateAndReturnsTrue`) to stop quoting the replaced literal expression, while keeping its asserted value (`"b"`) unchanged, per AC4.
+5. Reword the doc-comment sentence identified in R6 to name both preconditions for non-negativity.
+6. Do not touch `KbdActionsTests.cs` (R5 confirms no test in that file is affected).
+7. No retention-gate script, hook, or CI check needs updating (R7 — none exists live).
+
+## Rejected alternatives
+
+- **Option 1 (switch the guard to `StartsWith`)**: explicitly rejected by the maintainer decision; not evaluated further here.
+- **Leaving `IndexOf` culture-sensitive vs forcing `StringComparison.Ordinal`**: both are behaviorally equivalent for the actual digit-only input domain (R2) and neither is blocked or required by any live analyzer gate; `StringComparison.Ordinal` is recommended for semantic clarity but is not a correctness requirement for this fix.
+
+## Testing implications
+
+- Add exactly one new `[TestMethod]` to `KaStringAsyncTests.cs` for the AC3 regression case (`Key="01"`, `other="1"`, non-null `Update`, asserting `"1"`), following the file's existing MSTest + FluentAssertions + AAA + `because:` conventions (R8) and reusing the `NewKa` helper.
+- Reword (do not remove) the AC4 prefix-case assertion's because-string in the existing `KeyEquals_ContainsMatchWhileActivated_InvokesUpdateAndReturnsTrue` test so it no longer quotes the replaced literal expression (R4); the asserted value `"b"` itself does not change.
+- No other existing `KaStringAsyncTests.cs` test requires a value change (R4); `KbdActionsTests.cs` requires no change and must not be edited (R5, maintainer decision).
+- No coverage, retention-gate, or CI artifact needs updating as a prerequisite (R7).
+- This is unit-level, deterministic, no-I/O test work consistent with repository MSTest/FluentAssertions/AAA conventions; no new test infrastructure or seam is required.
diff --git a/docs/features/active/kastringasync-keyequals-contains-offset-583/spec.md b/docs/features/active/kastringasync-keyequals-contains-offset-583/spec.md
new file mode 100644
index 000000000..7c0157904
--- /dev/null
+++ b/docs/features/active/kastringasync-keyequals-contains-offset-583/spec.md
@@ -0,0 +1,334 @@
+# kastringasync-keyequals-contains-offset (Spec)
+
+- **Issue:** #583
+- **Parent (optional):** none
+- **Owner:** drmoisan
+- **Last Updated:** 2026-09-12T10-35
+- **Status:** Ready for implementation
+- **Version:** 1.0
+
+## Context
+
+- Summary of the bug and its impact: the first branch of KaStringAsync's KeyEquals method
+ guards on a substring test (Contains) but computes the argument it passes to the Update
+ callback using prefix-only arithmetic (a Substring offset derived from the probe's own
+ length, not from where the probe was found). The guard and the offset agree only when the
+ probe happens to be a prefix of the stored key. When the probe matches at a later position,
+ the offset expression returns the wrong character.
+- Observed environment(s): QuickFiler's keyboard-driven collection navigation, on the
+ KaStringAsync implementation of the generic keyboard-action list.
+- Customer impact and severity: recorded in issue.md as Low (latent). The defect has no
+ observable effect in the shipped product today because the only production construction
+ paths for a KaStringAsync instance leave its Update callback null; the branch that carries
+ the wrong offset is guarded by a null check on Update and therefore never invokes the
+ callback in production. This is a latent defect, not a live one.
+- First observed date and version(s) impacted: recorded 2026-09-11 as a maintainer decision on
+ issue #583; the underlying code has not changed since it was introduced.
+
+## Repro & Evidence
+
+- Steps to reproduce (code-level, not a live user-facing repro given the null-Update finding
+ above): construct a KaStringAsync instance with a non-null Update callback, an Activated
+ value of true, and a two-character Key whose second character equals a one-character probe
+ that is not the key's first character. Call KeyEquals with that probe. See the Regression
+ Case section below for the exact literal values.
+- Expected vs actual behavior: Update should receive the character at the position where the
+ probe actually matched inside the key. It instead receives a character computed purely from
+ the probe's own length, which is only correct when the probe is a prefix of the key.
+- Logs/screenshots/error snippets: none; this is a pure-logic defect discovered through static
+ review of KaStringAsync's KeyEquals method, not through an observed runtime failure. The
+ accompanying research record characterizes framework and repository behavior from source
+ inspection; it is not a runtime observation.
+- Frequency / determinism: deterministic and always reproducible for the input shapes
+ described above, given a non-null Update. Not applicable in current production use because
+ Update is never non-null there.
+
+## Scope & Non-Goals
+
+- In scope: the offset expression inside the Contains-guarded branch of KaStringAsync's
+ KeyEquals method, and its regression coverage in the associated test file.
+- Out of scope / non-goals: the second branch of KeyEquals (the single-character
+ non-match branch that toggles a control) and the third branch (the multi-character
+ non-match branch, whose own Update argument is already the key's first character
+ unconditionally and is not affected by this change) are both out of scope. No other method
+ of KaStringAsync, and no other implementation of the shared keyboard-action interface, is in
+ scope.
+- Explicitly excluded systems, integrations, or datasets: the pinned keyboard-matching test in
+ the QuickFiler.Test project's KbdActions test file is out of scope for editing. That file
+ must not be modified as part of this change; it is named here only to state that it is
+ excluded, not to claim it is touched. Likewise, the collection controller that registers the
+ two-digit keyboard keys, the keyboard-action interface and its other implementations, the
+ generic keyboard-actions collection class, and the keyboard dispatch handler are cited below
+ only as historical and structural context for why the defect is currently unreachable in
+ production; none of them is modified by this change. The archived feature folder that
+ previously recorded a one-time structural-count baseline for a related, already-closed issue
+ is likewise cited only as historical context and is not touched.
+
+## Root Cause Analysis
+
+- Confirmed root cause: inside KaStringAsync's KeyEquals method, the branch entered when the
+ stored key contains the probe as a substring computes its Update argument as a Substring
+ call whose start index is derived only from the probe's own length minus one. That
+ expression is correct only when the probe is a prefix of the key, because in that case the
+ probe's length minus one coincides with the index of the last matched character. When the
+ probe matches at a different position, the two computations diverge and the wrong character
+ is passed to Update.
+- Signals/evidence supporting it: confirmed by direct reading of the current implementation
+ (KaStringAsync.cs, the KeyEquals method, the Contains-guarded branch) and by the maintainer
+ decision recorded in issue.md, which independently describes the same divergence and
+ resolves it. The accompanying research record's finding R1 quotes the exact current
+ expression and its surrounding structure; finding R9 enumerates the reachable two-digit key
+ and probe combinations and shows exactly one combination where the current and corrected
+ expressions disagree.
+- Affected components/modules: `QuickFiler/Controllers/KaStringAsync.cs` (the KeyEquals
+ method) and its regression coverage in
+ `QuickFiler.Test/Controllers/KaStringAsyncTests.cs`.
+
+## Proposed Fix
+
+### Design summary (what changes where)
+
+Per the binding maintainer decision recorded in issue.md, this change keeps the Contains guard
+on the affected branch of KeyEquals unchanged and corrects only the offset expression that
+branch uses when it invokes Update. The offset is derived from the match position — the index
+at which the probe was actually found inside the key — rather than from the probe's own
+length alone.
+
+The recommended corrected expression, quoted from the research record's finding R1 and its
+Recommendation section, replaces the current expression with one derived from IndexOf: the
+Substring call's start index becomes the position returned by searching the key for the probe,
+plus the probe's own length, minus one. This yields the index of the last character of the
+matched span for every match position, not only for a prefix match. The research record notes
+this may optionally carry an explicit ordinal comparison argument on the IndexOf call; see the
+Comparison Semantics Decision section below for that determination.
+
+### Boundaries and invariants to preserve
+
+- The Contains guard on the affected branch is not changed in any way: neither its condition
+ nor a replacement with a prefix-only test is permitted. This is a binding maintainer
+ constraint, not a design preference.
+- The method's boolean return contract is unchanged: this fix affects only the argument passed
+ to the Update callback, never the value KeyEquals returns.
+- The null-probe and empty-probe guard clauses that run before the affected branch are
+ unchanged and continue to run first, in the same order.
+- The multi-character non-match branch's own Update argument (the key's first character,
+ unconditional on match position) is a separate, already-correct expression and is not
+ touched.
+
+### Dependencies or blocked work
+
+None. The fix is self-contained to the one method and its regression test file. No other
+in-flight work blocks or is blocked by this change.
+
+### Implementation strategy (what changes, not sequencing)
+
+#### Files/modules to change
+
+See the Write Set section below for the authoritative list of paths this change's diff
+creates, modifies, or deletes.
+
+#### Functions/classes/CLI commands impacted
+
+Only KaStringAsync's KeyEquals method is impacted at the production-code level. No public
+signature changes. No CLI or command-surface impact.
+
+#### Data flow and validation changes
+
+None. The guard clauses and their exception contracts (null-probe and empty-probe) are
+unchanged. Only the internal arithmetic feeding the Update callback's argument changes.
+
+#### Error handling and logging updates
+
+None required. No new failure mode is introduced; the corrected expression cannot produce a
+negative Substring start index, because the affected branch is only entered after the guard
+has already confirmed the probe is present in the key, and the probe's length is already
+confirmed to be at least one character by the preceding guard clause.
+
+#### Rollback/feature-flag considerations (if applicable)
+
+Not applicable. The change is not behind a feature flag; production behavior does not change
+today because Update is null on every production instance, so no rollout risk exists. Rollback
+is a plain source revert if ever needed.
+
+### Technical specifications (interfaces/contracts)
+
+#### Inputs/outputs and formats
+
+No change to KeyEquals's public signature (a single string parameter, a boolean return, the
+same two exception types on null and empty input). The only observable change under this fix
+is the character value passed to a non-null Update callback for a non-prefix match.
+
+#### Required configuration keys and defaults
+
+None.
+
+#### Backward-compatibility expectations
+
+Full backward compatibility is expected for every existing caller: the guard, the return
+value, and the exception contract are all unchanged. The corrected argument value only differs
+from today's value for match positions that are not currently exercised by any production
+construction path, per the research record's finding R3.
+
+#### Performance constraints (latency/throughput/memory)
+
+Not applicable. The change replaces one O(n) substring search argument computation with
+another of the same order; no measurable performance difference is expected or required.
+
+## Comparison Semantics Decision
+
+The research record's finding R2 establishes that Contains performs an ordinal (culture-
+insensitive) search on both the current and the legacy .NET Framework runtime, while the
+parameterless IndexOf overload performs a culture-sensitive search using the current culture
+on the legacy .NET Framework runtime this project targets. This is the asymmetry that the
+built-in specify-comparison analyzer rules exist to flag. The research record further
+establishes, as a static characterization derived from documented framework behavior over the
+enumerated input alphabet rather than from any runtime observation, that the two search modes
+cannot disagree for this method's actual input domain: the registered keys and probes are
+restricted to ASCII digit characters, which have no culture-specific collation or
+normalization differences under any standard culture.
+
+The research record also establishes that no analyzer override for either specify-comparison
+rule exists anywhere in the repository's analyzer configuration, and that the repository's
+blanket analyzer-severity default is set to the lowest, non-blocking severity level. Neither
+the analyzer rebuild command nor the nullable rebuild command promotes that severity level to
+a build failure. An explicit ordinal StringComparison argument on the IndexOf call is
+therefore not required by any active gate.
+
+Decision: the implementation adds an explicit ordinal StringComparison argument to the
+IndexOf call used in the corrected offset expression. This is not required for either rebuild
+command to pass, and it does not change behavior for the method's actual input domain, but it
+makes the corrected expression's comparison semantics textually consistent with the ordinal
+Contains guard it depends on, and it removes any ambiguity for a future reader or a future
+widening of the input domain.
+
+## Retention Gate Finding
+
+The research record's finding R7 searched the full repository for the literal expressions this
+change replaces and confirmed that the only prior structural-count check against them was a
+one-time, manually run baseline captured as a dated evidence table inside the archived feature
+folder for a separate, already-closed issue. That baseline was never wired into a script, a
+repository hook, or a continuous-integration workflow, and no such live enforcement point exists
+today. This change requires no update to any retention gate, coverage gate, or automated count,
+because none currently counts the literal being replaced.
+
+## Assumptions, Constraints, Dependencies
+
+- Assumptions (environment, data, access): the input alphabet reaching KeyEquals in production
+ is restricted to ASCII digit characters, per the research record's findings R2 and R9. No
+ change to that alphabet is assumed or required by this fix.
+- Constraints (budget, performance, compatibility): the binding maintainer decision constrains
+ the guard clause to remain a Contains-based substring test; no alternative guard design may
+ be substituted.
+- External dependencies (services, libraries, releases): none.
+
+## Data / API / Config Impact
+
+- User-facing or API changes: none. KeyEquals's public signature and exception contract are
+ unchanged.
+- Data or migration considerations: none.
+- Logging/telemetry updates (if any): none.
+- Compatibility notes (CLI flags, config schemas, versioning): none.
+
+## Test Strategy
+
+- Regression tests to add or update: one new regression test method is added to
+ `QuickFiler.Test/Controllers/KaStringAsyncTests.cs` covering the two-digit-width non-prefix
+ case described in the Regression Case section below. Following the file's established
+ conventions (per the research record's findings R4 and R8), the new test uses the existing
+ NewKa factory helper, MSTest attributes, FluentAssertions assertions with a because-style
+ explanation string, Arrange/Act/Assert section comments, and an Intent comment block naming
+ the defect being pinned and both the pre-fix and post-fix results.
+- Unit tests for the fixed behavior and boundaries: the new regression test, plus the existing
+ prefix-case test, together bound the corrected behavior at both the previously-passing
+ (prefix) case and the previously-failing (non-prefix) case.
+- Edge cases and negative scenarios: the existing null-probe and empty-probe tests already
+ cover the guard clauses that run before the affected branch and require no change, because
+ those guards are unchanged by this fix.
+- Error handling and logging verification: not applicable; no new error path is introduced.
+- Coverage impact and targets for changed lines/modules: the changed line is already exercised
+ by existing tests today; the new regression test adds coverage for a previously-untested
+ code path (a non-prefix match) rather than merely re-exercising an already-covered one.
+- Toolchain commands to run (format, lint, type-check, test): the CSharpier format command,
+ the CSharpier check command, the analyzer rebuild command, the nullable rebuild command, and
+ the MSTest run against the QuickFiler.Test assembly, all as defined in this repository's
+ CLAUDE.md, executed in that order and restarted from the beginning if any step fails or
+ auto-fixes any file.
+- Manual validation steps (if required): none; the defect has no observable production effect
+ today, so no manual verification beyond the automated toolchain is required.
+
+Additionally, one existing test's explanatory because-string is reworded because it quotes the
+literal expression being replaced; its asserted value does not change. See the Regression Case
+section below for that test's identity and current text.
+
+## Regression Case
+
+The primary regression case, carried forward from issue.md and confirmed by the research
+record's finding R9 as the only reachable two-digit key and single-character probe combination
+where the current and corrected expressions disagree: a Key equal to "01" and an other equal
+to "1", with a non-null Update, must cause Update to receive "1". Under the current,
+unfixed expression, Update instead receives "0".
+
+The pre-existing prefix-case example, which must remain unaffected by this fix: a Key equal to
+"abc" and an other equal to "ab", with a non-null Update, causes Update to receive "b" under
+both the current and the corrected expression, because "ab" is a prefix of "abc" and the two
+formulas agree whenever the probe is a prefix of the key.
+
+The one existing test whose because-string quotes the literal expression being replaced is the
+test named for a contains-match while activated that invokes Update and returns true, in
+`QuickFiler.Test/Controllers/KaStringAsyncTests.cs`. Its asserted value ("b") does not change;
+only its explanatory string is reworded so it no longer quotes the replaced literal expression,
+describing instead the last-character-of-the-matched-span reasoning that the corrected
+expression embodies.
+
+## Acceptance Criteria
+
+- [x] AC1: The recorded maintainer decision (retain the Contains guard, correct the offset
+ arithmetic) is reflected in the implementation; the branch-one guard text is unchanged and
+ no StartsWith call is introduced in `QuickFiler/Controllers/KaStringAsync.cs`.
+- [x] AC2: KaStringAsync's KeyEquals branch-one derives its Update argument from the match
+ position via Key.IndexOf(other), so the character passed to Update is the last character of
+ the matched span for a non-prefix match as well as for a prefix match.
+- [x] AC3: A regression test in `QuickFiler.Test/Controllers/KaStringAsyncTests.cs` covers the
+ two-digit-width non-prefix case described in the Regression Case section above, with a
+ non-null Update, and asserts that the received argument equals the corrected value stated
+ there.
+- [x] AC4: The pre-existing prefix-case behavior is preserved: for a Key equal to "abc" and an
+ other equal to "ab", Update still receives "b".
+- [x] AC5: The pinned keyboard-matching test in the QuickFiler.Test KbdActionsTests file passes
+ unchanged, and that file is not modified by this change.
+- [x] AC6: The full C# toolchain passes in order: CSharpier check, the analyzer rebuild, the
+ nullable rebuild, and the MSTest run.
+
+## Write Set
+
+- `QuickFiler/Controllers/KaStringAsync.cs`
+- `QuickFiler.Test/Controllers/KaStringAsyncTests.cs`
+- `docs/features/active/kastringasync-keyequals-contains-offset-583/issue.md`
+- `docs/features/active/kastringasync-keyequals-contains-offset-583/spec.md`
+- `docs/features/active/kastringasync-keyequals-contains-offset-583/plan.2026-09-12T10-25.md`
+- `docs/features/active/kastringasync-keyequals-contains-offset-583/research/2026-09-12T10-35-kastringasync-keyequals-contains-offset-research.md`
+- `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/baseline/`
+- `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/regression-testing/`
+- `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/qa-gates/`
+- `docs/features/active/kastringasync-keyequals-contains-offset-583/evidence/other/`
+
+## Risks & Mitigations
+
+- Technical or operational risks: minimal. The change is confined to one expression inside one
+ method, guarded by the same conditions as before, in a code path that no production
+ construction site currently exercises with a non-null Update. The primary risk is
+ introducing a regression in the already-passing prefix-case test; this is mitigated by
+ keeping that test's asserted value unchanged and only rewording its explanatory string.
+- Mitigations and rollbacks: the full toolchain run (format, analyzer rebuild, nullable
+ rebuild, test run) after the change is the primary verification gate. Rollback is a plain
+ source revert of the two changed source files if a regression is discovered.
+
+## Rollout & Follow-up
+
+- Release/rollout steps: standard pull-request merge; no flag, migration, or staged rollout is
+ required, because the change has no observable effect on current production behavior.
+- Post-fix monitoring or clean-up tasks: none identified. If a future change wires a non-null
+ Update callback into a production KaStringAsync construction site, that change should
+ re-verify this corrected offset behavior against the newly reachable code path.
+- Links: issue #583; the research record at
+ `docs/features/active/kastringasync-keyequals-contains-offset-583/research/2026-09-12T10-35-kastringasync-keyequals-contains-offset-research.md`.