From b2eb51938e1de9718265ca10d7d2040a0c9f52ec Mon Sep 17 00:00:00 2001 From: Derek Gabriel Date: Wed, 15 Apr 2026 09:34:16 -1000 Subject: [PATCH 1/3] Add EditTags tool for content-free tag edits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces EditTags(key, add?, remove?, project?) so callers can adjust document tag metadata without re-emitting the whole body through UpsertDocument. Diff semantics: add-and-remove are disjoint (a tag in both is rejected); already-present adds are no-ops; absent removes are idempotent; empty-both and no-effective-change paths short-circuit without a write. Tool flow lives in a new TagEditService / ITagEditService / TagEditResult trio that mirrors DocumentEditService for testability — 8 new unit tests against a fake store cover the full matrix. Full suite: 137/137 passing. Bumps csproj + host.json from 1.7.0 to 1.9.0 (catching up the drift through the 1.8.0 release) and ships a CHANGELOG entry, README row and workflow section, and seed usage-guide section. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 19 ++ README.md | 16 ++ docs/seed/ref-devbrain-usage.md | 23 ++ .../DevBrain.Functions.csproj | 2 +- src/DevBrain.Functions/Program.cs | 1 + .../Services/ITagEditService.cs | 11 + .../Services/TagEditResult.cs | 16 ++ .../Services/TagEditService.cs | 131 ++++++++++ src/DevBrain.Functions/Tools/DocumentTools.cs | 41 ++- src/DevBrain.Functions/host.json | 4 +- .../Services/TagEditServiceTests.cs | 245 ++++++++++++++++++ 11 files changed, 505 insertions(+), 4 deletions(-) create mode 100644 src/DevBrain.Functions/Services/ITagEditService.cs create mode 100644 src/DevBrain.Functions/Services/TagEditResult.cs create mode 100644 src/DevBrain.Functions/Services/TagEditService.cs create mode 100644 tests/DevBrain.Functions.Tests/Services/TagEditServiceTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index d02a97f..ccffac0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ All notable changes to DevBrain are tracked in this file. Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.9.0] — 2026-04-15 + +A new `EditTags` tool lets callers adjust tag metadata on an existing document without re-emitting its content. Previously the only way to add or drop a tag was a full `UpsertDocument` round-trip that re-sent the entire body — wasteful for large documents whose content isn't changing. + +### Added +- **`EditTags(key, add?, remove?, project?)`** — applies a tag diff to an existing document. `add` and `remove` are disjoint lists; a tag present in both is rejected. Already-present tags in `add` are no-ops; absent tags in `remove` are silently ignored (idempotent). Content is never touched — the call updates `tags`, `updatedAt`, and `updatedBy` only. Empty `add` and `remove` returns a "nothing to do" response without a write. A no-effective-change result (e.g. adding a tag that's already present) also short-circuits without a write. +- **`ITagEditService` + `TagEditService`** — encapsulates tag-diff computation, conflict detection, and the write decision. Mirrors the `DocumentEditService` pattern so tag edits are unit-testable against a fake store without touching Cosmos. +- **`TagEditResult`** — structured response with `Found`, `Changed`, `PreviousTags`, `Tags`, `Added`, `Removed`, `UpdatedAt`, `UpdatedBy`, and a human-readable `Message`. `Added`/`Removed` reflect the tags that actually changed, not the raw input lists — so idempotent no-ops are visible to the caller. + +### Changed +- `README.md` now documents the new `EditTags` tool and the "edit tags without re-upserting" workflow. +- `docs/seed/ref-devbrain-usage.md` now teaches AI callers when and how to use `EditTags` in place of a full upsert for tag-only changes. +- `host.json` MCP `instructions` updated to mention `EditTags`. +- `host.json` `serverVersion` and `DevBrain.Functions.csproj` `` bumped to `1.9.0`. (Note: both files had drifted at `1.7.0` through the 1.8.0 release — this bump catches them up.) + +### Notes +- **Concurrency.** `EditTags` uses a read-then-upsert path rather than a hash-guarded conditional write. A concurrent tag edit between the read and write could be clobbered. This matches the existing `UpsertDocument` concurrency posture and is acceptable for tag metadata in DevBrain's single-tenant deployment. If stronger guarantees are ever needed, the path can be promoted to `ReplaceIfHashMatchesAsync` without changing the tool surface. +- **Input normalization.** Blank / whitespace-only tags in `add` or `remove` are dropped before diffing. Duplicates within a single list are deduplicated. Tag comparison is ordinal (case-sensitive) — `"Draft"` and `"draft"` are distinct tags. + ## [1.8.0] — 2026-04-14 Two new document-editing tools add a safe preview/apply workflow for exact text replacements. This keeps DevBrain's whole-document storage model intact while giving AI callers a deterministic way to edit without stale overwrites. diff --git a/README.md b/README.md index 7d1cb0a..e8130c7 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,7 @@ All tools accept an optional `project` parameter (defaults to `"default"`) to is | `CompareDocument` | `key` (required), `content` or `contentHash` (one required), `project` | Check whether candidate content matches a stored document by SHA-256 hash | | `PreviewEditDocument` | `key` (required), `oldText` (required), `newText` (required), `expectedOccurrences`, `caseSensitive`, `project` | Preview a literal text replacement without writing; returns match count, before/after preview, and the current content hash | | `ApplyEditDocument` | `key` (required), `oldText` (required), `newText` (required), `expectedContentHash` (required), `expectedOccurrences`, `caseSensitive`, `project` | Apply a literal text replacement only if the document still matches the preview hash | +| `EditTags` | `key` (required), `add`, `remove`, `project` | Add and/or remove tags on a document without re-emitting content. A tag in both `add` and `remove` is rejected. | | `ListDocuments` | `prefix`, `project` | List document keys, optionally filtered by prefix | | `SearchDocuments` | `query` (required), `project` | Substring search across keys and content | | `DeleteDocument` | `key` (required), `project` | Delete a document by key. Idempotent on missing keys. | @@ -210,6 +211,21 @@ ApplyEditDocument( ) ``` +### Editing Tags Without Re-Upserting + +`EditTags` applies a tag diff to a document, leaving `content` untouched. Pass `add` and/or `remove` as disjoint lists — a tag that appears in both is rejected. Already-present tags in `add` are no-ops; absent tags in `remove` are silently ignored (idempotent). Both lists empty returns a "nothing to do" message without a write. + +```text +EditTags( + key="ref:devbrain-usage", + project="default", + add=["workflow"], + remove=["draft"] +) +``` + +Use `EditTags` whenever you only need to adjust tag metadata — it avoids the overhead of sending the entire document body through `UpsertDocument`. + ### When to use Append vs Chunked Both tools exist to work around the LLM-client per-turn output budget, but they solve different problems: diff --git a/docs/seed/ref-devbrain-usage.md b/docs/seed/ref-devbrain-usage.md index 4f8ae24..cf8a3e2 100644 --- a/docs/seed/ref-devbrain-usage.md +++ b/docs/seed/ref-devbrain-usage.md @@ -108,6 +108,29 @@ If the document changed after preview, apply refuses the write and tells you to - Use `caseSensitive: true` when casing matters and you want to avoid accidental matches - Use `UpsertDocument` when you are replacing the whole document on purpose; use preview/apply when you want a narrow, literal patch +## Editing Tags Without Re-Upserting + +To adjust tag metadata on an existing document, use `EditTags` instead of re-sending the full content through `UpsertDocument`. + +### Add and/or remove tags in one call +``` +EditTags( + key: "ref:devbrain-usage", + project: "default", + add: ["workflow"], + remove: ["draft"] +) +``` + +The server applies the diff: +- Tags in `add` that are already present are no-ops +- Tags in `remove` that are absent are ignored (idempotent) +- A tag that appears in both `add` and `remove` is rejected — the call fails without writing +- If `add` and `remove` are both empty, nothing is written +- If the resulting tag set is identical to the current one, nothing is written + +`EditTags` never modifies the document `content`. It touches `tags`, `updatedAt`, and `updatedBy` only. Use this whenever you just need to label or un-label an existing document — it's dramatically cheaper than an `UpsertDocument` that has to re-emit the whole body. + ## Key Conventions Keys use **colon** as the separator. Slash-separated keys (`sprint/foo`) still work for backward compatibility, but colons are the canonical, recommended convention — they signal "DevBrain key" at a glance and avoid being confused with file paths. diff --git a/src/DevBrain.Functions/DevBrain.Functions.csproj b/src/DevBrain.Functions/DevBrain.Functions.csproj index b89f993..7416f3f 100644 --- a/src/DevBrain.Functions/DevBrain.Functions.csproj +++ b/src/DevBrain.Functions/DevBrain.Functions.csproj @@ -4,7 +4,7 @@ Exe v4 6221c643-6ba8-4163-98a2-2241c8ddd211 - 1.7.0 + 1.9.0 diff --git a/src/DevBrain.Functions/Program.cs b/src/DevBrain.Functions/Program.cs index 623a15b..29dc524 100644 --- a/src/DevBrain.Functions/Program.cs +++ b/src/DevBrain.Functions/Program.cs @@ -73,6 +73,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); // ---------------- OAuth DCR facade (v1.6) ---------------- diff --git a/src/DevBrain.Functions/Services/ITagEditService.cs b/src/DevBrain.Functions/Services/ITagEditService.cs new file mode 100644 index 0000000..bc14369 --- /dev/null +++ b/src/DevBrain.Functions/Services/ITagEditService.cs @@ -0,0 +1,11 @@ +namespace DevBrain.Functions.Services; + +public interface ITagEditService +{ + Task EditTagsAsync( + string key, + string project, + string[] add, + string[] remove, + string updatedBy); +} diff --git a/src/DevBrain.Functions/Services/TagEditResult.cs b/src/DevBrain.Functions/Services/TagEditResult.cs new file mode 100644 index 0000000..aaf23bf --- /dev/null +++ b/src/DevBrain.Functions/Services/TagEditResult.cs @@ -0,0 +1,16 @@ +namespace DevBrain.Functions.Services; + +public sealed class TagEditResult +{ + public string Key { get; init; } = string.Empty; + public string Project { get; init; } = string.Empty; + public bool Found { get; init; } + public bool Changed { get; init; } + public string[] PreviousTags { get; init; } = []; + public string[] Tags { get; init; } = []; + public string[] Added { get; init; } = []; + public string[] Removed { get; init; } = []; + public DateTimeOffset? UpdatedAt { get; init; } + public string? UpdatedBy { get; init; } + public string Message { get; init; } = string.Empty; +} diff --git a/src/DevBrain.Functions/Services/TagEditService.cs b/src/DevBrain.Functions/Services/TagEditService.cs new file mode 100644 index 0000000..f66d09f --- /dev/null +++ b/src/DevBrain.Functions/Services/TagEditService.cs @@ -0,0 +1,131 @@ +using DevBrain.Functions.Models; + +namespace DevBrain.Functions.Services; + +public sealed class TagEditService : ITagEditService +{ + private readonly IDocumentStore _store; + + public TagEditService(IDocumentStore store) + { + _store = store; + } + + public async Task EditTagsAsync( + string key, + string project, + string[] add, + string[] remove, + string updatedBy) + { + var addSet = Normalize(add); + var removeSet = Normalize(remove); + + var conflicts = addSet.Intersect(removeSet, StringComparer.Ordinal).ToArray(); + if (conflicts.Length > 0) + { + return new TagEditResult + { + Key = key, + Project = project, + Message = $"Tag(s) appear in both 'add' and 'remove': {string.Join(", ", conflicts)}. A tag cannot be added and removed in the same call." + }; + } + + if (addSet.Length == 0 && removeSet.Length == 0) + { + return new TagEditResult + { + Key = key, + Project = project, + Message = "'add' and 'remove' are both empty — nothing to do." + }; + } + + var document = await _store.GetAsync(key, project); + if (document is null) + { + return new TagEditResult + { + Key = key, + Project = project, + Found = false, + Message = $"Document not found: '{key}'" + }; + } + + var existing = document.Tags ?? []; + var removeLookup = new HashSet(removeSet, StringComparer.Ordinal); + var existingLookup = new HashSet(existing, StringComparer.Ordinal); + + var resulting = new List(existing.Length + addSet.Length); + var seen = new HashSet(StringComparer.Ordinal); + foreach (var tag in existing) + { + if (removeLookup.Contains(tag)) continue; + if (seen.Add(tag)) resulting.Add(tag); + } + foreach (var tag in addSet) + { + if (seen.Add(tag)) resulting.Add(tag); + } + var newTags = resulting.ToArray(); + + var actuallyAdded = addSet.Where(t => !existingLookup.Contains(t)).ToArray(); + var actuallyRemoved = removeSet.Where(existingLookup.Contains).ToArray(); + + if (actuallyAdded.Length == 0 && actuallyRemoved.Length == 0) + { + return new TagEditResult + { + Key = document.Key, + Project = document.Project, + Found = true, + Changed = false, + PreviousTags = existing, + Tags = existing, + UpdatedAt = document.UpdatedAt, + UpdatedBy = document.UpdatedBy, + Message = "No tag changes needed — document already matches the requested state." + }; + } + + var updated = new BrainDocument + { + Id = document.Id, + Key = document.Key, + Project = document.Project, + Content = document.Content, + Tags = newTags, + UpdatedAt = DateTimeOffset.UtcNow, + UpdatedBy = updatedBy, + Ttl = document.Ttl + }; + + var saved = await _store.UpsertAsync(updated); + + return new TagEditResult + { + Key = saved.Key, + Project = saved.Project, + Found = true, + Changed = true, + PreviousTags = existing, + Tags = saved.Tags, + Added = actuallyAdded, + Removed = actuallyRemoved, + UpdatedAt = saved.UpdatedAt, + UpdatedBy = saved.UpdatedBy, + Message = $"Tags updated ({actuallyAdded.Length} added, {actuallyRemoved.Length} removed)." + }; + } + + private static string[] Normalize(string[]? tags) + { + if (tags is null || tags.Length == 0) return []; + return tags + .Where(t => !string.IsNullOrWhiteSpace(t)) + .Distinct(StringComparer.Ordinal) + .ToArray(); + } +} diff --git a/src/DevBrain.Functions/Tools/DocumentTools.cs b/src/DevBrain.Functions/Tools/DocumentTools.cs index 3d61c54..eacf999 100644 --- a/src/DevBrain.Functions/Tools/DocumentTools.cs +++ b/src/DevBrain.Functions/Tools/DocumentTools.cs @@ -10,11 +10,13 @@ public sealed class DocumentTools { private readonly IDocumentStore _store; private readonly IDocumentEditService _editService; + private readonly ITagEditService _tagEditService; - public DocumentTools(IDocumentStore store, IDocumentEditService editService) + public DocumentTools(IDocumentStore store, IDocumentEditService editService, ITagEditService tagEditService) { _store = store; _editService = editService; + _tagEditService = tagEditService; } [Function(nameof(UpsertDocument))] @@ -484,6 +486,43 @@ public async Task SearchDocuments( } } + [Function(nameof(EditTags))] + public async Task EditTags( + [McpToolTrigger("EditTags", "Add and/or remove tags on a document without re-emitting its content. Provide 'add' and/or 'remove' as disjoint tag lists; the server applies the diff and records updatedAt/updatedBy. Document content is untouched. A tag present in both 'add' and 'remove' is rejected.")] + ToolInvocationContext context, + [McpToolProperty("key", "Document key whose tags to edit.", isRequired: true)] + string key, + [McpToolProperty("add", "Tags to add. Already-present tags are kept as-is (no duplicates).")] + string[]? add, + [McpToolProperty("remove", "Tags to remove. Absent tags are ignored (idempotent).")] + string[]? remove, + [McpToolProperty("project", "Project scope (default: \"default\").")] + string? project, + FunctionContext functionContext) + { + var keyError = ValidateWriteKey(key); + if (keyError is not null) + { + return keyError; + } + + try + { + var result = await _tagEditService.EditTagsAsync( + key, + project ?? "default", + add ?? [], + remove ?? [], + GetCallerIdentity(functionContext)); + + return JsonSerializer.Serialize(result); + } + catch (Exception ex) + { + return $"Error editing tags: {ex.Message}"; + } + } + /// /// Enforces the colon-key convention on write paths. Writes that use '/' as a separator /// collide on id (EncodeId maps '/' → ':') but land in a different partition (raw key), diff --git a/src/DevBrain.Functions/host.json b/src/DevBrain.Functions/host.json index 14e7cb0..a8d63d7 100644 --- a/src/DevBrain.Functions/host.json +++ b/src/DevBrain.Functions/host.json @@ -23,9 +23,9 @@ "routePrefix": "" }, "mcp": { - "instructions": "DevBrain is a developer knowledge store. Use UpsertDocument to save sprint docs, architecture notes, or project state. Use GetDocument to retrieve by key. Use ListDocuments to browse what's stored. Use SearchDocuments to find content by keyword. Use DeleteDocument to remove a document. Use AppendDocument for append-only logs (session history, decision logs) where re-emitting the whole body is wasteful. Use UpsertDocumentChunked when a document is too large to emit in a single turn. Use GetDocumentMetadata to check if a document exists and inspect its size, hash, and timestamps without reading the full content. Use CompareDocument to check whether candidate content matches a stored document (by raw content or SHA-256 hash) before deciding whether to import or update. Keys use colon as separator: state:current, sprint:my-feature, ref:notes. Writes require colon keys.", + "instructions": "DevBrain is a developer knowledge store. Use UpsertDocument to save sprint docs, architecture notes, or project state. Use GetDocument to retrieve by key. Use ListDocuments to browse what's stored. Use SearchDocuments to find content by keyword. Use DeleteDocument to remove a document. Use AppendDocument for append-only logs (session history, decision logs) where re-emitting the whole body is wasteful. Use UpsertDocumentChunked when a document is too large to emit in a single turn. Use GetDocumentMetadata to check if a document exists and inspect its size, hash, and timestamps without reading the full content. Use CompareDocument to check whether candidate content matches a stored document (by raw content or SHA-256 hash) before deciding whether to import or update. Use PreviewEditDocument and ApplyEditDocument to make safe exact-text edits without re-emitting the whole document. Use EditTags to add and/or remove tags on a document without re-emitting its content. Keys use colon as separator: state:current, sprint:my-feature, ref:notes. Writes require colon keys.", "serverName": "DevBrain", - "serverVersion": "1.7.0", + "serverVersion": "1.9.0", "system": { "webhookAuthorizationLevel": "anonymous" } diff --git a/tests/DevBrain.Functions.Tests/Services/TagEditServiceTests.cs b/tests/DevBrain.Functions.Tests/Services/TagEditServiceTests.cs new file mode 100644 index 0000000..64af014 --- /dev/null +++ b/tests/DevBrain.Functions.Tests/Services/TagEditServiceTests.cs @@ -0,0 +1,245 @@ +using DevBrain.Functions.Models; +using DevBrain.Functions.Services; + +namespace DevBrain.Functions.Tests.Services; + +public sealed class TagEditServiceTests +{ + [Fact] + public async Task EditTagsAsync_AddsNewTags_PreservesExistingOrderAndContent() + { + var store = new FakeDocumentStore(Seed("state:current", "devbrain", "keep this body", ["alpha", "beta"])); + var service = new TagEditService(store); + + var result = await service.EditTagsAsync( + "state:current", + "devbrain", + add: ["gamma", "beta"], // beta already present → no-op on that one + remove: [], + updatedBy: "agent@example.com"); + + Assert.True(result.Found); + Assert.True(result.Changed); + Assert.Equal(["alpha", "beta", "gamma"], result.Tags); + Assert.Equal(["gamma"], result.Added); + Assert.Empty(result.Removed); + Assert.Equal("agent@example.com", result.UpdatedBy); + + var saved = await store.GetAsync("state:current", "devbrain"); + Assert.NotNull(saved); + Assert.Equal("keep this body", saved.Content); // content untouched + Assert.Equal(["alpha", "beta", "gamma"], saved.Tags); + } + + [Fact] + public async Task EditTagsAsync_RemovesTags_IgnoresMissingOnes() + { + var store = new FakeDocumentStore(Seed("state:current", "devbrain", "body", ["alpha", "beta", "gamma"])); + var service = new TagEditService(store); + + var result = await service.EditTagsAsync( + "state:current", + "devbrain", + add: [], + remove: ["beta", "never-was-here"], + updatedBy: "agent@example.com"); + + Assert.True(result.Changed); + Assert.Equal(["alpha", "gamma"], result.Tags); + Assert.Equal(["beta"], result.Removed); + Assert.Empty(result.Added); + } + + [Fact] + public async Task EditTagsAsync_AddAndRemoveInSameCall_AppliesBoth() + { + var store = new FakeDocumentStore(Seed("state:current", "devbrain", "body", ["alpha", "beta"])); + var service = new TagEditService(store); + + var result = await service.EditTagsAsync( + "state:current", + "devbrain", + add: ["gamma"], + remove: ["alpha"], + updatedBy: "agent@example.com"); + + Assert.True(result.Changed); + Assert.Equal(["beta", "gamma"], result.Tags); + Assert.Equal(["gamma"], result.Added); + Assert.Equal(["alpha"], result.Removed); + } + + [Fact] + public async Task EditTagsAsync_TagInBothAddAndRemove_RejectsAndDoesNotWrite() + { + var store = new FakeDocumentStore(Seed("state:current", "devbrain", "body", ["alpha"])); + var service = new TagEditService(store); + + var result = await service.EditTagsAsync( + "state:current", + "devbrain", + add: ["beta"], + remove: ["beta"], + updatedBy: "agent@example.com"); + + Assert.False(result.Found); + Assert.False(result.Changed); + Assert.Contains("both 'add' and 'remove'", result.Message); + + var saved = await store.GetAsync("state:current", "devbrain"); + Assert.NotNull(saved); + Assert.Equal(["alpha"], saved.Tags); + } + + [Fact] + public async Task EditTagsAsync_EmptyAddAndRemove_ReturnsNoopWithoutWriting() + { + var store = new FakeDocumentStore(Seed("state:current", "devbrain", "body", ["alpha"])); + var service = new TagEditService(store); + + var result = await service.EditTagsAsync( + "state:current", + "devbrain", + add: [], + remove: [], + updatedBy: "agent@example.com"); + + Assert.False(result.Changed); + Assert.Contains("nothing to do", result.Message); + Assert.Equal(0, store.UpsertCount); + } + + [Fact] + public async Task EditTagsAsync_NoEffectiveChange_DoesNotWrite() + { + var store = new FakeDocumentStore(Seed("state:current", "devbrain", "body", ["alpha", "beta"])); + var service = new TagEditService(store); + + var result = await service.EditTagsAsync( + "state:current", + "devbrain", + add: ["alpha"], // already present + remove: ["never-was-here"], // absent + updatedBy: "agent@example.com"); + + Assert.True(result.Found); + Assert.False(result.Changed); + Assert.Equal(["alpha", "beta"], result.Tags); + Assert.Equal(0, store.UpsertCount); + } + + [Fact] + public async Task EditTagsAsync_DocumentNotFound_ReturnsNotFound() + { + var store = new FakeDocumentStore(); + var service = new TagEditService(store); + + var result = await service.EditTagsAsync( + "state:missing", + "devbrain", + add: ["alpha"], + remove: [], + updatedBy: "agent@example.com"); + + Assert.False(result.Found); + Assert.False(result.Changed); + Assert.Contains("not found", result.Message); + } + + [Fact] + public async Task EditTagsAsync_DedupesAndIgnoresBlanksInInput() + { + var store = new FakeDocumentStore(Seed("state:current", "devbrain", "body", [])); + var service = new TagEditService(store); + + var result = await service.EditTagsAsync( + "state:current", + "devbrain", + add: ["alpha", "alpha", " ", "", "beta"], + remove: [], + updatedBy: "agent@example.com"); + + Assert.True(result.Changed); + Assert.Equal(["alpha", "beta"], result.Tags); + } + + private static BrainDocument Seed(string key, string project, string content, string[] tags) => new() + { + Id = key, + Key = key, + Project = project, + Content = content, + Tags = tags, + UpdatedAt = DateTimeOffset.UtcNow.AddMinutes(-5), + UpdatedBy = "seed" + }; + + private sealed class FakeDocumentStore : IDocumentStore + { + private readonly Dictionary<(string Key, string Project), BrainDocument> _documents = new(); + public int UpsertCount { get; private set; } + + public FakeDocumentStore(params BrainDocument[] documents) + { + foreach (var document in documents) + { + document.ContentHash ??= ContentHashing.ComputeSha256(document.Content); + document.ContentLength ??= document.Content.Length; + _documents[(document.Key, document.Project)] = Clone(document); + } + } + + public Task UpsertAsync(BrainDocument document) + { + UpsertCount++; + document.Id = string.IsNullOrEmpty(document.Id) ? document.Key : document.Id; + document.ContentHash = ContentHashing.ComputeSha256(document.Content); + document.ContentLength = document.Content.Length; + _documents[(document.Key, document.Project)] = Clone(document); + return Task.FromResult(Clone(document)); + } + + public Task GetAsync(string key, string project) + { + _documents.TryGetValue((key, project), out var document); + return Task.FromResult(document is null ? null : Clone(document)); + } + + public Task ReplaceIfHashMatchesAsync(BrainDocument document, string expectedContentHash) => + throw new NotSupportedException(); + + public Task> ListAsync(string project, string? prefix = null) => + Task.FromResult>([]); + + public Task> SearchAsync(string query, string project) => + Task.FromResult>([]); + + public Task GetMetadataAsync(string key, string project) => + GetAsync(key, project); + + public Task TouchAllAsync() => Task.FromResult(_documents.Count); + + public Task DeleteAsync(string key, string project) => + Task.FromResult(_documents.Remove((key, project))); + + public Task AppendAsync(string key, string project, string content, string separator, string[] tags, string updatedBy) => + throw new NotSupportedException(); + + public Task UpsertChunkAsync(string key, string project, string content, int chunkIndex, int totalChunks, string[] tags, string updatedBy) => + throw new NotSupportedException(); + + private static BrainDocument Clone(BrainDocument document) => new() + { + Id = document.Id, + Key = document.Key, + Project = document.Project, + Content = document.Content, + Tags = [.. document.Tags], + UpdatedAt = document.UpdatedAt, + UpdatedBy = document.UpdatedBy, + ContentHash = document.ContentHash, + ContentLength = document.ContentLength, + Ttl = document.Ttl + }; + } +} From 22efa4be7e5e5f95c10e0e8a399328424fddd1c3 Mon Sep 17 00:00:00 2001 From: Derek Gabriel Date: Wed, 15 Apr 2026 09:43:37 -1000 Subject: [PATCH 2/3] Refresh .NET 10 + other dep versions; resolve new CVEs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps Microsoft.AspNetCore.DataProtection 10.0.0 -> 10.0.6, which transitively floors System.Security.Cryptography.Xml to 10.0.6 and clears the CVE-2026-33116 / CVE-2026-26171 DoS advisories published 2026-04-15 (vulnerable <= 10.0.5). No explicit sub-dep pin needed — the whole .NET 10 servicing line moves together. Also refreshes the Azure, IdentityModel, and test packages to current: - Azure.Identity 1.20.0 -> 1.21.0 - Azure.Extensions.AspNetCore.DataProtection.Blobs 1.5.0 -> 1.5.1 - Azure.Extensions.AspNetCore.DataProtection.Keys 1.5.0 -> 1.6.1 - Microsoft.IdentityModel.JsonWebTokens 8.3.0 -> 8.17.0 - Microsoft.IdentityModel.Protocols.OpenIdConnect 8.3.0 -> 8.17.0 - xunit 2.9.2 -> 2.9.3 - Microsoft.Extensions.TimeProvider.Testing 9.3.0 -> 10.5.0 Skipped: Microsoft.ApplicationInsights.WorkerService 2->3, Test.Sdk 17->18, xunit.runner.visualstudio 3.1.5 (v3-only) — all carry breaking changes worth handling in a dedicated bump. Build: 0 warnings, 0 errors. Tests: 137/137 passing. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/DevBrain.Functions/DevBrain.Functions.csproj | 12 ++++++------ .../DevBrain.Functions.Tests.csproj | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/DevBrain.Functions/DevBrain.Functions.csproj b/src/DevBrain.Functions/DevBrain.Functions.csproj index 7416f3f..e7abe34 100644 --- a/src/DevBrain.Functions/DevBrain.Functions.csproj +++ b/src/DevBrain.Functions/DevBrain.Functions.csproj @@ -16,13 +16,13 @@ - + - - - - - + + + + + diff --git a/tests/DevBrain.Functions.Tests/DevBrain.Functions.Tests.csproj b/tests/DevBrain.Functions.Tests/DevBrain.Functions.Tests.csproj index e1b28d8..6cab2d8 100644 --- a/tests/DevBrain.Functions.Tests/DevBrain.Functions.Tests.csproj +++ b/tests/DevBrain.Functions.Tests/DevBrain.Functions.Tests.csproj @@ -9,12 +9,12 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + From d4508c3c50e7d5070f13daa3afd795d6f7642541 Mon Sep 17 00:00:00 2001 From: Derek Gabriel Date: Wed, 15 Apr 2026 09:47:48 -1000 Subject: [PATCH 3/3] Revert DataProtection to 10.0.0; pin only the vulnerable sub-dep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Microsoft.AspNetCore.DataProtection 10.0.6 has a regression on Linux: ManagedAuthenticatedEncryptor fails same-process Protect/Unprotect roundtrips with "The payload was invalid" (MAC validation failure). Green on Windows, red on Ubuntu CI — UpstreamTokenProtectorTests .Protect_Unprotect_RoundTripsEnvelopeExactly reproduces it cleanly. Roll DataProtection back to 10.0.0 (where 1.8.0 shipped green) and pin System.Security.Cryptography.Xml 10.0.6 directly as a top-level PackageReference. That patches CVE-2026-33116 + CVE-2026-26171 without pulling in the regressed runtime library. Revisit when 10.0.7+ ships. Tests: 137/137 passing. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/DevBrain.Functions/DevBrain.Functions.csproj | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/DevBrain.Functions/DevBrain.Functions.csproj b/src/DevBrain.Functions/DevBrain.Functions.csproj index e7abe34..30dedbf 100644 --- a/src/DevBrain.Functions/DevBrain.Functions.csproj +++ b/src/DevBrain.Functions/DevBrain.Functions.csproj @@ -18,12 +18,18 @@ - + + +