Concept subject registry and concepts plan|apply host command

ConceptSubjectRegistry is the only writer of ConceptSubject. ConceptKey is the
one key grammar (anchored with \z), shared with ConceptAppraisalService.
Not yet built or tested at this commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-10 19:58:04 +02:00
commit b17436ce4f
8 changed files with 701 additions and 8 deletions

View file

@ -1,5 +1,4 @@
using System.Net;
using System.Text.RegularExpressions;
using Bifrost.Web.Configuration;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
@ -40,8 +39,6 @@ public sealed class ConceptAppraisalService(
{
private const string UniqueViolation = "23505";
private static readonly Regex Slug = new("^[a-z0-9-]{1,80}$", RegexOptions.CultureInvariant);
public async Task<CastResult> CastAsync(
string collection,
string key,
@ -260,7 +257,7 @@ public sealed class ConceptAppraisalService(
}
private Task<ConceptSubject?> FindSubjectAsync(string collection, string key, CancellationToken cancellationToken) =>
Slug.IsMatch(collection) && Slug.IsMatch(key)
ConceptKey.IsValid(collection) && ConceptKey.IsValid(key)
? dbContext.ConceptSubjects
.SingleOrDefaultAsync(x => x.Collection == collection && x.Key == key, cancellationToken)
: Task.FromResult<ConceptSubject?>(null);

View file

@ -0,0 +1,56 @@
using Microsoft.Extensions.DependencyInjection;
namespace Bifrost.Web.Features.ConceptVotes;
// Host command: Bifrost.Web.dll concepts plan|apply <collection> <manifest.json> [--retire k1,k2]
public static class ConceptCommand
{
public const int Ok = 0;
public const int Refused = 1;
public const int Usage = 2;
// args are the words after "concepts".
public static async Task<int> RunAsync(string[] args, IServiceProvider services, TextWriter output)
{
var known = args.Length > 0 && args[0] is "plan" or "apply";
var hasRetire = args.Length == 5 && args[3] == "--retire";
if (!known || !(args.Length == 3 || hasRetire))
{
await output.WriteLineAsync("usage: concepts plan|apply <collection> <manifest.json> [--retire k1,k2]");
return Usage;
}
string[] retire = hasRetire ? args[4].Split(',', StringSplitOptions.RemoveEmptyEntries) : [];
try
{
string json;
try
{
json = await File.ReadAllTextAsync(args[2]);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
throw new ManifestRefusedException("manifest", "Unreadable");
}
var ships = ConceptSubjectRegistry.ParseManifest(json);
await using var scope = services.CreateAsyncScope();
var registry = scope.ServiceProvider.GetRequiredService<ConceptSubjectRegistry>();
var plan = args[0] == "apply"
? await registry.ApplyAsync(args[1], ships, retire, default)
: await registry.PlanAsync(args[1], ships, retire, default);
foreach (var entry in plan.Entries)
{
await output.WriteLineAsync($"{entry.Change.ToString().ToLowerInvariant()} {entry.Key}");
}
return Ok;
}
catch (ManifestRefusedException ex)
{
await output.WriteLineAsync($"refused {ex.Field} {ex.Code}");
return Refused;
}
}
}

View file

@ -0,0 +1,13 @@
using System.Text.RegularExpressions;
namespace Bifrost.Web.Features.ConceptVotes;
// The one grammar for a concept collection or key. \z, not $: $ admits a trailing line feed.
public static class ConceptKey
{
public const int MaxLength = 80;
private static readonly Regex Shape = new(@"^[a-z0-9-]{1,80}\z", RegexOptions.CultureInvariant);
public static bool IsValid(string? value) => value is not null && Shape.IsMatch(value);
}

View file

@ -0,0 +1,225 @@
using System.Text.Json;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Shared;
using Microsoft.EntityFrameworkCore;
namespace Bifrost.Web.Features.ConceptVotes;
public enum SubjectChange
{
Add,
Rename,
Unchanged,
Revive,
Absent,
Retire
}
public sealed record ManifestShip(string Key, string Title);
public sealed record SubjectPlanEntry(SubjectChange Change, string Key, string Title);
public sealed record SubjectPlan(string Collection, IReadOnlyList<SubjectPlanEntry> Entries);
// A refusal names the field and a code, never the offending value.
public sealed class ManifestRefusedException(string field, string code)
: Exception($"{field}: {code}")
{
public string Field { get; } = field;
public string Code { get; } = code;
}
// The only writer of ConceptSubject: decides which concept keys exist, their titles and their retirement.
public sealed class ConceptSubjectRegistry(
BifrostDbContext dbContext,
AuditTrailService auditTrail,
TimeProvider timeProvider)
{
public const int MaxTitleLength = 240;
// The Mill manifest is JSON from outside the Verse. Only ships[].id and ships[].name are read.
public static IReadOnlyList<ManifestShip> ParseManifest(string json)
{
JsonDocument document;
try
{
document = JsonDocument.Parse(json);
}
catch (JsonException)
{
throw new ManifestRefusedException("manifest", "NotJson");
}
using (document)
{
if (document.RootElement.ValueKind != JsonValueKind.Object ||
!document.RootElement.TryGetProperty("ships", out var ships) ||
ships.ValueKind != JsonValueKind.Array)
{
throw new ManifestRefusedException("ships", "Missing");
}
var result = new List<ManifestShip>();
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var ship in ships.EnumerateArray())
{
var at = $"ships[{result.Count}]";
var key = ReadString(ship, "id", at);
var title = ReadString(ship, "name", at);
if (!ConceptKey.IsValid(key))
{
throw new ManifestRefusedException($"{at}.id", "InvalidKey");
}
if (!seen.Add(key))
{
throw new ManifestRefusedException($"{at}.id", "Duplicate");
}
if (string.IsNullOrWhiteSpace(title) || title.Any(char.IsControl))
{
throw new ManifestRefusedException($"{at}.name", "InvalidTitle");
}
if (title.Length > MaxTitleLength)
{
throw new ManifestRefusedException($"{at}.name", "TooLong");
}
result.Add(new ManifestShip(key, title));
}
return result;
}
}
public async Task<SubjectPlan> PlanAsync(
string collection,
IReadOnlyList<ManifestShip> ships,
IReadOnlyCollection<string> retire,
CancellationToken cancellationToken)
{
if (!ConceptKey.IsValid(collection))
{
throw new ManifestRefusedException("collection", "InvalidKey");
}
var named = new HashSet<string>(StringComparer.Ordinal);
foreach (var key in retire)
{
if (!ConceptKey.IsValid(key))
{
throw new ManifestRefusedException("retire", "InvalidKey");
}
named.Add(key);
}
var registered = await dbContext.ConceptSubjects.AsNoTracking()
.Where(x => x.Collection == collection)
.ToDictionaryAsync(x => x.Key, StringComparer.Ordinal, cancellationToken);
var entries = new List<SubjectPlanEntry>();
foreach (var ship in ships)
{
var change = !registered.TryGetValue(ship.Key, out var row)
? SubjectChange.Add
: row.RetiredAtUtc is not null
? SubjectChange.Revive
: row.Title != ship.Title ? SubjectChange.Rename : SubjectChange.Unchanged;
entries.Add(new SubjectPlanEntry(change, ship.Key, ship.Title));
}
var inManifest = ships.Select(x => x.Key).ToHashSet(StringComparer.Ordinal);
var absent = registered.Values
.Where(x => x.RetiredAtUtc is null && !inManifest.Contains(x.Key))
.OrderBy(x => x.Key, StringComparer.Ordinal)
.ToList();
foreach (var subject in absent)
{
entries.Add(new SubjectPlanEntry(
named.Remove(subject.Key) ? SubjectChange.Retire : SubjectChange.Absent,
subject.Key,
subject.Title));
}
// Whatever is still named is not a registered, live key missing from the manifest.
if (named.Count > 0)
{
throw new ManifestRefusedException("retire", "NotAbsent");
}
return new SubjectPlan(collection, entries);
}
public async Task<SubjectPlan> ApplyAsync(
string collection,
IReadOnlyList<ManifestShip> ships,
IReadOnlyCollection<string> retire,
CancellationToken cancellationToken)
{
var plan = await PlanAsync(collection, ships, retire, cancellationToken);
var rows = await dbContext.ConceptSubjects
.Where(x => x.Collection == collection)
.ToDictionaryAsync(x => x.Key, StringComparer.Ordinal, cancellationToken);
foreach (var entry in plan.Entries)
{
if (entry.Change is SubjectChange.Unchanged or SubjectChange.Absent)
{
continue;
}
ConceptSubject subject;
if (entry.Change == SubjectChange.Add)
{
subject = new ConceptSubject
{
Collection = collection,
Key = entry.Key,
Title = entry.Title,
RegisteredAtUtc = timeProvider.GetUtcNow()
};
dbContext.ConceptSubjects.Add(subject);
}
else
{
subject = rows[entry.Key];
if (entry.Change == SubjectChange.Retire)
{
subject.RetiredAtUtc = timeProvider.GetUtcNow();
}
else
{
subject.Title = entry.Title;
subject.RetiredAtUtc = null;
}
}
// The audit write saves the subject change in the same SaveChanges.
await auditTrail.RecordAsync(
null,
nameof(ConceptSubject),
subject.Id,
entry.Change.ToString(),
$"actor=host-command collection={collection} key={entry.Key}",
cancellationToken);
}
return plan;
}
private static string ReadString(JsonElement ship, string name, string at)
{
if (ship.ValueKind != JsonValueKind.Object || !ship.TryGetProperty(name, out var value))
{
throw new ManifestRefusedException($"{at}.{name}", "Missing");
}
return value.ValueKind == JsonValueKind.String
? value.GetString()!
: throw new ManifestRefusedException($"{at}.{name}", "NotString");
}
}

View file

@ -24,6 +24,23 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using System.Net;
// Host command: registers Mill concept subjects with configuration and the database only; no web app, no migrations.
if (args.Length > 0 && args[0] == "concepts")
{
var host = Host.CreateApplicationBuilder([]);
host.Services.AddDbContext<BifrostDbContext>(options => options.UseNpgsql(BifrostConnectionString(host.Configuration)));
host.Services.AddSingleton(TimeProvider.System);
host.Services.AddScoped<AuditTrailService>();
host.Services.AddScoped<ConceptSubjectRegistry>();
using var commandHost = host.Build();
Environment.ExitCode = await ConceptCommand.RunAsync(args[1..], commandHost.Services, Console.Out);
return;
}
static string BifrostConnectionString(IConfiguration configuration) =>
configuration.GetConnectionString("Bifrost")
?? "Host=127.0.0.1;Database=bifrost;Username=postgres;Password=postgres";
var builder = WebApplication.CreateBuilder(args);
builder.Services
@ -70,10 +87,7 @@ builder.Services.ConfigureHttpJsonOptions(options =>
builder.Services.AddDbContext<BifrostDbContext>(options =>
{
var connectionString = builder.Configuration.GetConnectionString("Bifrost")
?? "Host=127.0.0.1;Database=bifrost;Username=postgres;Password=postgres";
options.UseNpgsql(connectionString);
options.UseNpgsql(BifrostConnectionString(builder.Configuration));
});
builder.Services.AddHttpLogging(options =>
@ -103,6 +117,7 @@ builder.Services.AddScoped<PatronageService>();
builder.Services.AddScoped<StripeWebhookService>();
builder.Services.AddScoped<HeimdallPatronSupportIntakeService>();
builder.Services.AddScoped<ConceptAppraisalService>();
builder.Services.AddScoped<ConceptSubjectRegistry>();
builder.Services.AddScoped<ReadinessService>();
builder.Services.AddScoped<ApplicationBootstrapper>();
builder.Services.AddSingleton<StartupConfigurationValidator>();

View file

@ -20,6 +20,10 @@
<ProjectReference Include="..\..\src\Bifrost.Web\Bifrost.Web.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="Fixtures\*.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>

View file

@ -0,0 +1,374 @@
using System.Net;
using Bifrost.Web.Configuration;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.ConceptVotes;
using Bifrost.Web.Features.Shared;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace Bifrost.Web.Tests;
public sealed class ConceptSubjectRegistryTests
{
private const string Collection = "aetheria-concept-mill";
private static readonly string[] None = [];
private static string FixturePath => Path.Combine(AppContext.BaseDirectory, "Fixtures", "mill-three-ships.json");
private static IReadOnlyList<ManifestShip> Fixture() =>
ConceptSubjectRegistry.ParseManifest(File.ReadAllText(FixturePath));
[Fact]
public async Task Rerun_of_the_same_manifest_is_a_no_op()
{
using var world = new World();
var first = await world.Apply(Fixture());
Assert.All(first.Entries, e => Assert.Equal(SubjectChange.Add, e.Change));
Assert.Equal(3, await world.Fresh(db => db.AuditEvents.CountAsync(x => x.EntityType == nameof(ConceptSubject))));
var before = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().OrderBy(x => x.Key).ToListAsync());
world.Clock.Advance(TimeSpan.FromHours(1));
var second = await world.Apply(Fixture());
Assert.All(second.Entries, e => Assert.Equal(SubjectChange.Unchanged, e.Change));
Assert.Equal(3, await world.Fresh(db => db.AuditEvents.CountAsync()));
var after = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().OrderBy(x => x.Key).ToListAsync());
Assert.Equal(before.Select(x => (x.Id, x.Title, x.RegisteredAtUtc, x.RetiredAtUtc)),
after.Select(x => (x.Id, x.Title, x.RegisteredAtUtc, x.RetiredAtUtc)));
}
[Fact]
public async Task Apply_writes_one_host_command_audit_event_per_change_and_none_for_a_plan()
{
using var world = new World();
var plan = await world.Plan(Fixture());
Assert.All(plan.Entries, e => Assert.Equal(SubjectChange.Add, e.Change));
Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync()));
Assert.Empty(await world.Fresh(db => db.AuditEvents.ToListAsync()));
await world.Apply(Fixture());
var events = await world.Fresh(db => db.AuditEvents.ToListAsync());
var subjectIds = (await world.Fresh(db => db.ConceptSubjects.ToListAsync())).Select(x => x.Id.ToString()).ToHashSet();
Assert.Equal(3, events.Count);
Assert.All(events, e =>
{
Assert.Equal(nameof(ConceptSubject), e.EntityType);
Assert.Equal("Add", e.Action);
Assert.Null(e.ActorUserAccountId);
Assert.Contains("actor=host-command", e.Detail);
Assert.Contains(e.EntityId, subjectIds);
});
}
[Fact]
public async Task Rename_changes_title_and_keeps_votes()
{
using var world = new World();
await world.Apply(Fixture());
await world.Cast("bison", ConceptStance.Up);
await world.Cast("kestrel", ConceptStance.Down);
var renamed = Fixture().Select(s => s.Key == "bison" ? s with { Title = "Bison Prime" } : s).ToList();
var plan = await world.Apply(renamed);
Assert.Equal(SubjectChange.Rename, plan.Entries.Single(e => e.Key == "bison").Change);
Assert.Equal(2, plan.Entries.Count(e => e.Change == SubjectChange.Unchanged));
var subject = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().SingleAsync(x => x.Key == "bison"));
Assert.Equal("Bison Prime", subject.Title);
Assert.Null(subject.RetiredAtUtc);
Assert.Equal(2, await world.Fresh(db => db.ConceptAppraisals.CountAsync()));
var view = await world.Fresh(db => world.Appraisals(db).GetSubjectAsync(Collection, "bison", null, default));
Assert.Equal("Bison Prime", view!.Title);
Assert.Equal(new ConceptTally(1, 0, new StanceCounts(0, 0)), view.Tally);
Assert.Equal(1, await world.Fresh(db => db.AuditEvents.CountAsync(x => x.Action == "Rename")));
}
[Fact]
public async Task Absent_key_is_retired_only_when_named()
{
using var world = new World();
await world.Apply(Fixture());
await world.Cast("kestrel", ConceptStance.Up);
var smaller = Fixture().Where(s => s.Key != "kestrel").ToList();
var unnamed = await world.Apply(smaller);
Assert.Equal(SubjectChange.Absent, unnamed.Entries.Single(e => e.Key == "kestrel").Change);
Assert.Null((await world.Fresh(db => db.ConceptSubjects.AsNoTracking().SingleAsync(x => x.Key == "kestrel"))).RetiredAtUtc);
Assert.Contains("kestrel", (await world.Fresh(db => world.Appraisals(db).GetTalliesAsync(Collection, default))).Keys);
var named = await world.Apply(smaller, ["kestrel"]);
Assert.Equal(SubjectChange.Retire, named.Entries.Single(e => e.Key == "kestrel").Change);
var retired = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().SingleAsync(x => x.Key == "kestrel"));
Assert.NotNull(retired.RetiredAtUtc);
Assert.Equal(1, await world.Fresh(db => db.ConceptAppraisals.CountAsync()));
Assert.DoesNotContain("kestrel", (await world.Fresh(db => world.Appraisals(db).GetTalliesAsync(Collection, default))).Keys);
Assert.Equal(CastResult.RetiredSubject, await world.Cast("kestrel", ConceptStance.Down));
Assert.Equal(1, await world.Fresh(db => db.AuditEvents.CountAsync(x => x.Action == "Retire")));
}
[Theory]
[InlineData("bison")]
[InlineData("never-registered")]
public async Task Retire_naming_a_key_that_is_not_registered_live_and_absent_is_refused_whole(string named)
{
using var world = new World();
await world.Apply(Fixture());
var renamed = Fixture().Select(s => s.Key == "kestrel" ? s with { Title = "Kestrel II" } : s).ToList();
var refusal = await Assert.ThrowsAsync<ManifestRefusedException>(() => world.Apply(renamed, [named]));
Assert.Equal(("retire", "NotAbsent"), (refusal.Field, refusal.Code));
Assert.DoesNotContain(named, refusal.Message);
Assert.Equal("Kestrel", (await world.Fresh(db => db.ConceptSubjects.AsNoTracking().SingleAsync(x => x.Key == "kestrel"))).Title);
Assert.Equal(3, await world.Fresh(db => db.AuditEvents.CountAsync()));
}
[Fact]
public async Task Naming_an_already_retired_key_is_refused()
{
using var world = new World();
await world.Apply(Fixture());
var smaller = Fixture().Where(s => s.Key != "kestrel").ToList();
await world.Apply(smaller, ["kestrel"]);
await Assert.ThrowsAsync<ManifestRefusedException>(() => world.Apply(smaller, ["kestrel"]));
}
[Fact]
public async Task Re_registering_a_retired_key_revives_it()
{
using var world = new World();
await world.Apply(Fixture());
await world.Cast("kestrel", ConceptStance.Up);
await world.Apply(Fixture().Where(s => s.Key != "kestrel").ToList(), ["kestrel"]);
var plan = await world.Apply(Fixture());
Assert.Equal(SubjectChange.Revive, plan.Entries.Single(e => e.Key == "kestrel").Change);
var subject = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().SingleAsync(x => x.Key == "kestrel"));
Assert.Null(subject.RetiredAtUtc);
var tallies = await world.Fresh(db => world.Appraisals(db).GetTalliesAsync(Collection, default));
Assert.Equal(new ConceptTally(1, 0, new StanceCounts(0, 0)), tallies["kestrel"]);
Assert.Equal(1, await world.Fresh(db => db.AuditEvents.CountAsync(x => x.Action == "Revive")));
}
[Theory]
[InlineData("""{"ships":[{"id":"a","name":"A"},{"id":"a","name":"B"}]}""", "ships[1].id", "Duplicate")]
[InlineData("""{"ships":[{"id":"Upper","name":"A"}]}""", "ships[0].id", "InvalidKey")]
[InlineData("""{"ships":[{"id":"a/b","name":"A"}]}""", "ships[0].id", "InvalidKey")]
[InlineData("""{"ships":[{"id":"ship-a\n","name":"A"}]}""", "ships[0].id", "InvalidKey")]
[InlineData("""{"ships":[{"id":"","name":"A"}]}""", "ships[0].id", "InvalidKey")]
[InlineData("""{"ships":[{"id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","name":"A"}]}""", "ships[0].id", "InvalidKey")]
[InlineData("""{"ships":[{"id":"a"}]}""", "ships[0].name", "Missing")]
[InlineData("""{"ships":[{"id":"a","name":7}]}""", "ships[0].name", "NotString")]
[InlineData("""{"ships":[{"id":"a","name":" "}]}""", "ships[0].name", "InvalidTitle")]
[InlineData("""{"ships":[{"id":"a","name":"bad\ntitle"}]}""", "ships[0].name", "InvalidTitle")]
[InlineData("""{"ships":["a"]}""", "ships[0].id", "Missing")]
[InlineData("""{"fleet":[]}""", "ships", "Missing")]
[InlineData("not json", "manifest", "NotJson")]
public async Task Malformed_manifest_is_refused_whole(string json, string field, string code)
{
using var world = new World();
var refusal = Assert.Throws<ManifestRefusedException>(() => ConceptSubjectRegistry.ParseManifest(json));
Assert.Equal((field, code), (refusal.Field, refusal.Code));
Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync()));
}
[Fact]
public async Task A_bad_entry_after_good_ones_refuses_the_file_and_writes_no_row()
{
using var world = new World();
var json = """{"ships":[{"id":"good","name":"Good"},{"id":"ship-a\n","name":"Bad"}]}""";
Assert.Throws<ManifestRefusedException>(() => ConceptSubjectRegistry.ParseManifest(json));
Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync()));
}
[Fact]
public async Task Refusal_never_echoes_the_offending_value()
{
const string canary = "CANARY-secret/value";
var json = $$"""{"ships":[{"id":"{{canary}}","name":"A"}]}""";
var refusal = Assert.Throws<ManifestRefusedException>(() => ConceptSubjectRegistry.ParseManifest(json));
Assert.DoesNotContain("CANARY", refusal.Message);
using var world = new World();
var collection = await Assert.ThrowsAsync<ManifestRefusedException>(() => world.Registry(world.NewDb()).PlanAsync(canary, [], None, default));
Assert.DoesNotContain("CANARY", collection.Message);
var retire = await Assert.ThrowsAsync<ManifestRefusedException>(() => world.Registry(world.NewDb()).PlanAsync(Collection, [], [canary], default));
Assert.DoesNotContain("CANARY", retire.Message);
}
[Theory]
[InlineData("ship-a\n")]
[InlineData("ship-a\r\n")]
[InlineData("ship-a ")]
public async Task The_key_grammar_rejects_a_trailing_line_feed_wherever_it_is_reused(string forged)
{
using var world = new World();
await world.Apply(Fixture());
await world.Registry(world.NewDb()).ApplyAsync(Collection, [new ManifestShip("ship-a", "A")], None, default);
Assert.False(ConceptKey.IsValid(forged));
Assert.False(ConceptKey.IsValid("key\n"));
Assert.True(ConceptKey.IsValid("ship-a"));
Assert.Equal(CastResult.UnknownSubject, await world.Cast(forged, ConceptStance.Up));
Assert.Null(await world.Fresh(db => world.Appraisals(db).GetSubjectAsync(Collection, forged, null, default)));
await Assert.ThrowsAsync<ManifestRefusedException>(() =>
world.Registry(world.NewDb()).PlanAsync(Collection + "\n", [], None, default));
await Assert.ThrowsAsync<ManifestRefusedException>(() =>
world.Registry(world.NewDb()).PlanAsync(Collection, [], ["ship-a\n"], default));
Assert.Empty(await world.Fresh(db => db.ConceptAppraisals.ToListAsync()));
}
[Fact]
public async Task Host_command_plans_and_applies_with_exit_codes()
{
using var world = new World();
var services = world.Services();
var plan = new StringWriter();
Assert.Equal(ConceptCommand.Ok, await ConceptCommand.RunAsync(["plan", Collection, FixturePath], services, plan));
Assert.Contains("add bison", plan.ToString());
Assert.Contains("add ibis-mk2", plan.ToString());
Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync()));
Assert.Empty(await world.Fresh(db => db.AuditEvents.ToListAsync()));
var apply = new StringWriter();
Assert.Equal(ConceptCommand.Ok, await ConceptCommand.RunAsync(["apply", Collection, FixturePath], services, apply));
Assert.Equal(3, await world.Fresh(db => db.ConceptSubjects.CountAsync()));
var again = new StringWriter();
Assert.Equal(ConceptCommand.Ok, await ConceptCommand.RunAsync(["apply", Collection, FixturePath], services, again));
Assert.Contains("unchanged bison", again.ToString());
Assert.Equal(3, await world.Fresh(db => db.AuditEvents.CountAsync()));
}
[Fact]
public async Task Host_command_retire_flag_retires_the_named_absent_key()
{
using var world = new World();
var services = world.Services();
await ConceptCommand.RunAsync(["apply", Collection, FixturePath], services, new StringWriter());
var smaller = Path.Combine(world.Dir, "two.json");
await File.WriteAllTextAsync(smaller, """{"ships":[{"id":"bison","name":"Bison"},{"id":"ibis-mk2","name":"Ibis Mk II"}]}""");
var output = new StringWriter();
Assert.Equal(ConceptCommand.Ok, await ConceptCommand.RunAsync(["apply", Collection, smaller, "--retire", "kestrel"], services, output));
Assert.Contains("retire kestrel", output.ToString());
Assert.NotNull((await world.Fresh(db => db.ConceptSubjects.AsNoTracking().SingleAsync(x => x.Key == "kestrel"))).RetiredAtUtc);
}
[Fact]
public async Task Host_command_exits_non_zero_on_refusal_and_never_echoes_input()
{
using var world = new World();
var services = world.Services();
var bad = Path.Combine(world.Dir, "bad.json");
await File.WriteAllTextAsync(bad, """{"ships":[{"id":"CANARY/Id","name":"A"}]}""");
var refused = new StringWriter();
Assert.Equal(ConceptCommand.Refused, await ConceptCommand.RunAsync(["apply", Collection, bad], services, refused));
Assert.Contains("ships[0].id", refused.ToString());
Assert.DoesNotContain("CANARY", refused.ToString());
var missing = new StringWriter();
Assert.Equal(ConceptCommand.Refused, await ConceptCommand.RunAsync(["apply", Collection, Path.Combine(world.Dir, "CANARY-missing.json")], services, missing));
Assert.DoesNotContain("CANARY", missing.ToString());
Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync()));
}
[Theory]
[InlineData("frobnicate", "c", "m")]
[InlineData("plan", "c")]
[InlineData("apply", "c", "m", "--bogus", "x")]
[InlineData("apply", "c", "m", "--retire")]
public async Task Host_command_unknown_subcommand_or_shape_exits_2(params string[] args)
{
using var world = new World();
var output = new StringWriter();
Assert.Equal(ConceptCommand.Usage, await ConceptCommand.RunAsync(args, world.Services(), output));
Assert.StartsWith("usage:", output.ToString());
Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync()));
Assert.Equal(ConceptCommand.Usage, await ConceptCommand.RunAsync([], world.Services(), new StringWriter()));
}
private sealed class World : IDisposable
{
private readonly string _dbName = $"registry-{Guid.NewGuid():N}";
public World()
{
Dir = Path.Combine(Path.GetTempPath(), $"registry-{Guid.NewGuid():N}");
Directory.CreateDirectory(Dir);
}
public string Dir { get; }
public ManualClock Clock { get; } = new(new DateTimeOffset(2026, 10, 10, 12, 0, 0, TimeSpan.Zero));
public BifrostDbContext NewDb() =>
new(new DbContextOptionsBuilder<BifrostDbContext>().UseInMemoryDatabase(_dbName).Options);
public ConceptSubjectRegistry Registry(BifrostDbContext db) =>
new(db, new AuditTrailService(db, Clock), Clock);
public ConceptAppraisalService Appraisals(BifrostDbContext db) =>
new(db, new AuditTrailService(db, Clock), Clock,
Options.Create(new ConceptVoteOptions { NetworkTagSecret = "test-tag-secret" }));
public async Task<SubjectPlan> Apply(IReadOnlyList<ManifestShip> ships, string[]? retire = null)
{
await using var db = NewDb();
return await Registry(db).ApplyAsync(Collection, ships, retire ?? None, default);
}
public async Task<SubjectPlan> Plan(IReadOnlyList<ManifestShip> ships)
{
await using var db = NewDb();
return await Registry(db).PlanAsync(Collection, ships, None, default);
}
public async Task<CastResult> Cast(string key, ConceptStance stance)
{
await using var db = NewDb();
var voter = VoterIdentity.For(CurrentBifrostActor.Anonymous, "device-" + key + stance)!;
return await Appraisals(db).CastAsync(Collection, key, voter, stance, IPAddress.Parse("203.0.113.7"), default);
}
public async Task<T> Fresh<T>(Func<BifrostDbContext, Task<T>> read)
{
await using var db = NewDb();
return await read(db);
}
public IServiceProvider Services()
{
var services = new ServiceCollection();
services.AddDbContext<BifrostDbContext>(o => o.UseInMemoryDatabase(_dbName));
services.AddSingleton<TimeProvider>(Clock);
services.AddScoped<AuditTrailService>();
services.AddScoped<ConceptSubjectRegistry>();
return services.BuildServiceProvider();
}
public void Dispose() => Directory.Delete(Dir, true);
}
private sealed class ManualClock(DateTimeOffset start) : TimeProvider
{
private DateTimeOffset _now = start;
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan by) => _now += by;
}
}

View file

@ -0,0 +1,9 @@
{
"sections": [],
"votes": {},
"ships": [
{ "id": "bison", "name": "Bison", "code": "AU", "role": "Swarm escort" },
{ "id": "ibis-mk2", "name": "Ibis Mk II", "code": "AU" },
{ "id": "kestrel", "name": "Kestrel" }
]
}