diff --git a/Aetheria.State.AuthoritySmoke/Program.cs b/Aetheria.State.AuthoritySmoke/Program.cs index 863918294..036dab214 100644 --- a/Aetheria.State.AuthoritySmoke/Program.cs +++ b/Aetheria.State.AuthoritySmoke/Program.cs @@ -1230,6 +1230,7 @@ internal sealed class AuthoritySmokeChecks Phase = "pre-wave" }).ConfigureAwait(false); await node.FlushAsync().ConfigureAwait(false); + await node.RefreshRuntimeCatalogAsync().ConfigureAwait(false); var catalog = await node.RuntimeCatalog().LatestAsync().ConfigureAwait(false); Require(catalog.Corporations.Any(corporation => !string.IsNullOrWhiteSpace(corporation.CorporationKey)), diff --git a/Aetheria.State.Daemon.Smoke/Program.cs b/Aetheria.State.Daemon.Smoke/Program.cs index 4c42391ee..a2d648eab 100644 --- a/Aetheria.State.Daemon.Smoke/Program.cs +++ b/Aetheria.State.Daemon.Smoke/Program.cs @@ -5860,6 +5860,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks AetheriaDaemonNativeCatalog.EnsureAsync(node).GetAwaiter().GetResult(); node.FlushAsync().GetAwaiter().GetResult(); + node.RefreshRuntimeCatalogAsync().GetAwaiter().GetResult(); var catalog = node.RuntimeCatalog().Latest(); var hull = catalog.FindItem(AetheriaDaemonNativeCatalog.DockyardHullItemKey); var bay = catalog.FindItem(AetheriaDaemonNativeCatalog.DockyardBayItemKey); diff --git a/Aetheria.State.Daemon/Program.cs b/Aetheria.State.Daemon/Program.cs index a2887b434..c2e07db53 100644 --- a/Aetheria.State.Daemon/Program.cs +++ b/Aetheria.State.Daemon/Program.cs @@ -84,6 +84,8 @@ await AetheriaDaemonNativeCatalog.EnsureAsync(node).ConfigureAwait(false); TraceStartup("native-catalog"); await node.FlushAsync().ConfigureAwait(false); TraceStartup("initial-flush"); +await node.RefreshRuntimeCatalogAsync().ConfigureAwait(false); +TraceStartup("runtime-catalog"); await EnsurePlayableRunDocumentsAsync(node, options, startedAtUtc).ConfigureAwait(false); TraceStartup("playable-run"); await EnsureGameSessionAsync(node, options, startedAtUtc).ConfigureAwait(false); @@ -1991,7 +1993,7 @@ static async Task PublishDaemonMenuSurfacesAsync( var updatedAtUtc = string.IsNullOrWhiteSpace(frame.PublishedAtUtc) ? DateTimeOffset.UtcNow.ToString("O") : frame.PublishedAtUtc; - var catalog = AetheriaRuntimeCatalogStore.OpenReadOnly(node.StatePath); + var catalog = node.RuntimeCatalog().Latest(); var assetManifest = AetheriaRuntimeAssets.ProjectManifest( catalog, frame.Run?.RunId ?? frame.SessionId, diff --git a/Aetheria.State.Import/Program.cs b/Aetheria.State.Import/Program.cs index 5b6279bf9..df9bc2683 100644 --- a/Aetheria.State.Import/Program.cs +++ b/Aetheria.State.Import/Program.cs @@ -210,6 +210,7 @@ if (args.Any(argument => string.Equals(argument, "--merge-mine", StringCompariso await mergeNode.MutableDocument(AetheriaCatalogKeys.ItemDefinitionFromLegacyId(mine.LegacyId)) .ReplaceAsync(mine); await mergeNode.FlushAsync(); + await mergeNode.RefreshRuntimeCatalogAsync(); var merged = await mergeNode.RuntimeCatalog().LatestAsync().ConfigureAwait(false); var mergedMine = merged.FindItem(mine.ItemKey); if (mergedMine == null || !mergedMine.BehaviorPayloads.Any(behavior => @@ -229,6 +230,7 @@ if (args.Any(argument => string.Equals(argument, "--merge-station-sensor", Strin await mergeNode.MutableDocument(AetheriaCatalogKeys.ItemDefinitionFromLegacyId(stationHull.LegacyId)) .ReplaceAsync(stationHull); await mergeNode.FlushAsync(); + await mergeNode.RefreshRuntimeCatalogAsync(); var merged = await mergeNode.RuntimeCatalog().LatestAsync().ConfigureAwait(false); var mergedHull = merged.FindItem(stationHull.ItemKey); if (mergedHull == null || !(mergedHull.Hardpoints ?? []).Any(hardpoint => @@ -338,7 +340,7 @@ foreach (var nameFile in parsedNameFiles) } await node.FlushAsync(); -var runtimeCatalog = await node.RuntimeCatalog().LatestAsync().ConfigureAwait(false); +var runtimeCatalog = await node.RefreshRuntimeCatalogAsync().ConfigureAwait(false); var importedCorporationKeys = runtimeCatalog.Corporations .Select(corporation => corporation.CorporationKey) .Where(key => !string.IsNullOrWhiteSpace(key)) @@ -369,6 +371,7 @@ await node.MutableDocument(AetheriaStateNode.TradeValu runtimeCatalog.TradeValueSettings, capturedAtUtc)); await node.FlushAsync(); +await node.RefreshRuntimeCatalogAsync().ConfigureAwait(false); await node.CatalogSurface().LatestAsync().ConfigureAwait(false); await node.FlushAsync(); diff --git a/Aetheria.State.Smoke/Program.cs b/Aetheria.State.Smoke/Program.cs index 5cba8875a..1cf04e0e7 100644 --- a/Aetheria.State.Smoke/Program.cs +++ b/Aetheria.State.Smoke/Program.cs @@ -98,6 +98,7 @@ await using (var node = await AetheriaStateNode.OpenAsync(statePath, "aetheria-s }); await node.FlushAsync(); + await node.RefreshRuntimeCatalogAsync(); var runtimeCatalog = await node.RuntimeCatalog().LatestAsync().ConfigureAwait(false); await node.MutableDocument(AetheriaStateNode.CatalogSurfaceKey) .ReplaceAsync(AetheriaEveSurfaceDocuments.BuildCatalogSurface(runtimeCatalog, now)); diff --git a/Aetheria.State.Verify/Program.cs b/Aetheria.State.Verify/Program.cs index 2d5e72f5c..d01b237f1 100644 --- a/Aetheria.State.Verify/Program.cs +++ b/Aetheria.State.Verify/Program.cs @@ -126,6 +126,7 @@ await using var node = await AetheriaStateNode.OpenAsync( verificationStatePath, "aetheria-state-verify", enableDurableShardLogs: false); +await node.RefreshRuntimeCatalogAsync().ConfigureAwait(false); var ledger = await node.MutableDocument(AetheriaStateNode.MigrationLedgerKey).ReadAsync() ?? throw new InvalidOperationException("Missing typed migration ledger."); @@ -12985,6 +12986,7 @@ static void RequireUnityRuntimeCatalogClientUsesManagedDocument(string root) static void RequireCatalogSurfaceUsesManagedRuntimeCatalog(string root) { + var stateNodePath = Path.Combine(root, "Aetheria.State", "AetheriaStateNode.cs"); var surfaceDocumentsPath = Path.Combine(root, "Aetheria.State", "AetheriaEveSurfaceDocuments.cs"); var bridgePath = Path.Combine(root, "Aetheria.State", "AetheriaEveCommandBridge.cs"); var importPath = Path.Combine(root, "Aetheria.State.Import", "Program.cs"); @@ -12998,6 +13000,37 @@ static void RequireCatalogSurfaceUsesManagedRuntimeCatalog(string root) var import = File.Exists(importPath) ? File.ReadAllText(importPath) : throw new InvalidOperationException("Cannot verify legacy catalog import; Aetheria.State.Import/Program.cs is missing."); + var stateNode = File.Exists(stateNodePath) + ? File.ReadAllText(stateNodePath) + : throw new InvalidOperationException("Cannot verify runtime catalog ownership; AetheriaStateNode.cs is missing."); + + var requiredStateNodeSymbols = new[] + { + "Database.GetAsync(RuntimeCatalogKey)", + "Database.WatchRecord(RuntimeCatalogKey)", + "public async Task RefreshRuntimeCatalogAsync()", + "await Database.PutAsync(RuntimeCatalogKey, snapshot).ConfigureAwait(false);", + "_ => catalog.Watch()" + }; + var missingStateNodeSymbols = requiredStateNodeSymbols + .Where(symbol => !stateNode.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (missingStateNodeSymbols.Length > 0) + { + throw new InvalidOperationException( + "The state node must own one explicitly refreshed runtime catalog document: " + + string.Join(", ", missingStateNodeSymbols)); + } + + var runtimeCatalogMethod = stateNode[ + stateNode.IndexOf("public CultMeshDocumentHandle RuntimeCatalog()", StringComparison.Ordinal).. + stateNode.IndexOf("public CultMeshDocumentHandle CatalogSurface()", StringComparison.Ordinal)]; + if (runtimeCatalogMethod.Contains("DaemonFrameLatest", StringComparison.Ordinal) || + runtimeCatalogMethod.Contains("OpenReadOnly(StatePath)", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "Daemon frames must not invalidate or recompile the runtime catalog."); + } var requiredSurfaceDocumentSymbols = new[] { @@ -13068,6 +13101,7 @@ static void RequireCatalogSurfaceUsesManagedRuntimeCatalog(string root) "bb5d9f73-612f-4060-8fb4-f72d6b93b616", "rarity:rare", "--merge-station-sensor", + "RefreshRuntimeCatalogAsync()", "await node.CatalogSurface().LatestAsync().ConfigureAwait(false);" }; var missingImportSymbols = requiredImportSymbols diff --git a/Aetheria.State/AetheriaDocumentRegistry.cs b/Aetheria.State/AetheriaDocumentRegistry.cs index a89f636c0..86a04c5b1 100644 --- a/Aetheria.State/AetheriaDocumentRegistry.cs +++ b/Aetheria.State/AetheriaDocumentRegistry.cs @@ -32,6 +32,7 @@ public static class AetheriaDocumentRegistry typeof(AetheriaMigrationLedger), typeof(AetheriaLegacyCatalogQuarantine), typeof(AetheriaTradeValuePolicy), + typeof(AetheriaRuntimeCatalogSnapshot), typeof(AetheriaPlayerSettings), typeof(AetheriaLoadoutTemplate), typeof(AetheriaRunState), diff --git a/Aetheria.State/AetheriaEveCommandBridge.cs b/Aetheria.State/AetheriaEveCommandBridge.cs index ba879a3b6..519c2d87b 100644 --- a/Aetheria.State/AetheriaEveCommandBridge.cs +++ b/Aetheria.State/AetheriaEveCommandBridge.cs @@ -54,7 +54,8 @@ public static class AetheriaEveCommandBridge switch (command.Kind) { case AetheriaRuntimeEveCommandKind.CatalogRefresh: - var catalog = await node.RuntimeCatalog().LatestAsync().ConfigureAwait(false); + await node.FlushAsync().ConfigureAwait(false); + var catalog = await node.RefreshRuntimeCatalogAsync().ConfigureAwait(false); await node.MutableDocument(AetheriaStateNode.CatalogSurfaceKey) .ReplaceAsync(AetheriaEveSurfaceDocuments.BuildCatalogSurface(catalog, command.IssuedAtUtc)) .ConfigureAwait(false); diff --git a/Aetheria.State/AetheriaStateNode.cs b/Aetheria.State/AetheriaStateNode.cs index 9a624465c..d231738c7 100644 --- a/Aetheria.State/AetheriaStateNode.cs +++ b/Aetheria.State/AetheriaStateNode.cs @@ -109,11 +109,13 @@ public sealed class AetheriaStateNode : IAsyncDisposable, IDisposable return _runtimeCatalog ??= CultMesh.Document( "aetheria.catalog.runtime", CultMesh.Verse("aetheria.local", RuntimeId), - _ => Task.FromResult(AetheriaRuntimeCatalogStore.OpenReadOnly(StatePath)), - _ => Database.WatchRecord( - AetheriaRuntimeVerseRecordKeys.DaemonFrameLatest) + async _ => await Database.GetAsync(RuntimeCatalogKey) + .ConfigureAwait(false) + ?? throw new InvalidDataException( + "The compiled Aetheria runtime catalog is missing. Refresh it after changing source catalog records."), + _ => Database.WatchRecord(RuntimeCatalogKey) .Where(change => change.Document != null) - .Select(_ => AetheriaRuntimeCatalogStore.OpenReadOnly(StatePath)), + .Select(change => change.Document!), sources: new[] { CultMesh.ProjectionSource("catalog:aetheria.runtime"), @@ -134,11 +136,9 @@ public sealed class AetheriaStateNode : IAsyncDisposable, IDisposable _ => Task.FromResult(AetheriaEveSurfaceDocuments.BuildCatalogSurface( catalog.Latest(), DateTimeOffset.UtcNow.ToString("O"))), - _ => Database.WatchRecord( - AetheriaRuntimeVerseRecordKeys.DaemonFrameLatest) - .Where(change => change.Document != null) - .Select(_ => AetheriaEveSurfaceDocuments.BuildCatalogSurface( - catalog.Latest(), + _ => catalog.Watch() + .Select(snapshot => AetheriaEveSurfaceDocuments.BuildCatalogSurface( + snapshot, DateTimeOffset.UtcNow.ToString("O"))), sources: new[] { @@ -154,6 +154,13 @@ public sealed class AetheriaStateNode : IAsyncDisposable, IDisposable routeHint: new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "Aetheria typed catalog Eve surface")); } + public async Task RefreshRuntimeCatalogAsync() + { + var snapshot = AetheriaRuntimeCatalogStore.OpenReadOnly(StatePath); + await Database.PutAsync(RuntimeCatalogKey, snapshot).ConfigureAwait(false); + return snapshot; + } + public Task> SubmitDaemonCommandAsync( AetheriaRuntimeDaemonCommandDocument command) { @@ -242,6 +249,9 @@ public sealed class AetheriaStateNode : IAsyncDisposable, IDisposable public static CultRecordKey CatalogSurfaceKey { get; } = new(AetheriaEveSurfaceDocuments.CatalogSurfaceKey); + public static CultRecordKey RuntimeCatalogKey { get; } = + new("global:aetheria.runtime_catalog.v1"); + public static CultRecordKey OperationsSurfaceKey { get; } = new(AetheriaEveSurfaceDocuments.OperationsSurfaceKey); diff --git a/docs/aetheria-perfect-machine-map.md b/docs/aetheria-perfect-machine-map.md index 359f5f2eb..751b0a2bc 100644 --- a/docs/aetheria-perfect-machine-map.md +++ b/docs/aetheria-perfect-machine-map.md @@ -3607,6 +3607,26 @@ Live client readiness and durable publication are separate authorities: the released generic EveUnity witness proves provider discovery, mapped assets, QUIC SoA, commands and receipts while persistence runs concurrently. +The runtime catalog has one compiled-state owner: + +- Owner: `AetheriaStateNode` owns the compiled + `AetheriaRuntimeCatalogSnapshot`; item, corporation, name-file, and trade + policy documents remain the authored source records. +- Inputs: an explicit catalog refresh reads the durable source records after + their write has been flushed. +- Outputs: one typed runtime-catalog record feeds simulation, asset projection, + loadout generation, and the Eve catalog surface. +- Derived state: the Eve catalog surface is a projection of that record. Daemon + frames and their timestamps have no catalog-freshness authority. +- Forbidden writers: simulation ticks, daemon-frame publication, Unity clients, + and surface refreshes cannot silently recompile or replace the catalog. +- Shared paths: import, daemon-native catalog augmentation, and the explicit Eve + catalog-refresh command use the same compile-and-publish primitive. +- Cut line: runtime reads and catalog-surface watches no longer reopen the + CultCache store or subscribe to daemon-frame changes. +- Verification layer: state verification requires the typed record/watch path + and rejects daemon-frame invalidation or disk parsing inside `RuntimeCatalog`. + Remote realtime transport has one narrow owner: - Owner: the Aetheria client CultMesh host owns the provider QUIC listener;