Implement Advisory Canonicalization and Backfill Migration
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Added AdvisoryCanonicalizer for canonicalizing advisory identifiers. - Created EnsureAdvisoryCanonicalKeyBackfillMigration to populate advisory_key and links in advisory_raw documents. - Introduced FileSurfaceManifestStore for managing surface manifests with file system backing. - Developed ISurfaceManifestReader and ISurfaceManifestWriter interfaces for reading and writing manifests. - Implemented SurfaceManifestPathBuilder for constructing paths and URIs for surface manifests. - Added tests for FileSurfaceManifestStore to ensure correct functionality and deterministic behavior. - Updated documentation for new features and migration steps.
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.FS;
|
||||
|
||||
/// <summary>
|
||||
/// File-system backed manifest store for surface artefacts.
|
||||
/// </summary>
|
||||
public sealed class FileSurfaceManifestStore :
|
||||
ISurfaceManifestWriter,
|
||||
ISurfaceManifestReader
|
||||
{
|
||||
private readonly ILogger<FileSurfaceManifestStore> _logger;
|
||||
private readonly SurfaceManifestPathBuilder _pathBuilder;
|
||||
private readonly SemaphoreSlim _publishGate = new(1, 1);
|
||||
|
||||
public FileSurfaceManifestStore(
|
||||
IOptions<SurfaceCacheOptions> cacheOptions,
|
||||
IOptions<SurfaceManifestStoreOptions> storeOptions,
|
||||
ILogger<FileSurfaceManifestStore> logger)
|
||||
{
|
||||
if (cacheOptions is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(cacheOptions));
|
||||
}
|
||||
|
||||
if (storeOptions is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(storeOptions));
|
||||
}
|
||||
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_pathBuilder = new SurfaceManifestPathBuilder(cacheOptions.Value, storeOptions.Value);
|
||||
}
|
||||
|
||||
public async Task<SurfaceManifestPublishResult> PublishAsync(
|
||||
SurfaceManifestDocument document,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (document is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(document));
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var normalized = Normalize(document);
|
||||
var payload = SurfaceCacheJsonSerializer.Serialize(normalized);
|
||||
var digest = ComputeDigest(payload.Span);
|
||||
var digestHex = SurfaceManifestPathBuilder.EnsureSha256Digest(digest);
|
||||
|
||||
var path = _pathBuilder.BuildManifestPath(normalized.Tenant, digestHex);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
|
||||
await _publishGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var shouldWrite = true;
|
||||
|
||||
if (File.Exists(path))
|
||||
{
|
||||
var existing = await File.ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
var existingDigest = ComputeDigest(existing);
|
||||
if (!digest.Equals(existingDigest, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Surface manifest collision for {Path}; overwriting with new digest {Digest}.",
|
||||
path,
|
||||
digest);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Surface manifest reuse for {Path} (digest {Digest}).", path, digest);
|
||||
shouldWrite = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldWrite)
|
||||
{
|
||||
await File.WriteAllBytesAsync(path, payload.ToArray(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_publishGate.Release();
|
||||
}
|
||||
|
||||
var uri = _pathBuilder.BuildManifestUri(normalized.Tenant, digestHex);
|
||||
var artifactId = $"surface:{Sanitize(normalized.Tenant)}:{digestHex}";
|
||||
|
||||
_logger.LogInformation(
|
||||
"Published surface manifest for tenant {Tenant} with digest {Digest}.",
|
||||
normalized.Tenant,
|
||||
digest);
|
||||
|
||||
return new SurfaceManifestPublishResult(digest, uri, artifactId, normalized);
|
||||
}
|
||||
|
||||
public async Task<SurfaceManifestDocument?> TryGetByDigestAsync(
|
||||
string manifestDigest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var digestHex = SurfaceManifestPathBuilder.EnsureSha256Digest(manifestDigest);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// We don't know the tenant from digest alone; iterate tenant directories.
|
||||
foreach (var tenantDirectory in EnumerateTenantDirectories(_pathBuilder.RootDirectory))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var path = Path.Combine(
|
||||
tenantDirectory,
|
||||
digestHex[..2],
|
||||
digestHex[2..4],
|
||||
$"{digestHex}.json");
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var bytes = await File.ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
var existingDigest = ComputeDigest(bytes);
|
||||
if (!existingDigest.Equals($"sha256:{digestHex}", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
return SurfaceCacheJsonSerializer.Deserialize<SurfaceManifestDocument>(bytes);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public Task<SurfaceManifestDocument?> TryGetByUriAsync(
|
||||
string manifestUri,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var pointer = SurfaceManifestPathBuilder.ParseUri(manifestUri);
|
||||
var path = _pathBuilder.BuildManifestPath(pointer.TenantSegment, pointer.DigestHex);
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return Task.FromResult<SurfaceManifestDocument?>(null);
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var bytes = File.ReadAllBytes(path);
|
||||
return Task.FromResult<SurfaceManifestDocument?>(
|
||||
SurfaceCacheJsonSerializer.Deserialize<SurfaceManifestDocument>(bytes));
|
||||
}
|
||||
|
||||
private static SurfaceManifestDocument Normalize(SurfaceManifestDocument document)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(document.Tenant))
|
||||
{
|
||||
throw new ArgumentException("Surface manifest tenant cannot be empty.", nameof(document));
|
||||
}
|
||||
|
||||
var generatedAt = document.GeneratedAt == DateTimeOffset.MinValue
|
||||
? DateTimeOffset.MinValue
|
||||
: document.GeneratedAt.ToUniversalTime();
|
||||
|
||||
var artifacts = document.Artifacts
|
||||
.Select(NormalizeArtifact)
|
||||
.OrderBy(static a => a.Kind, StringComparer.Ordinal)
|
||||
.ThenBy(static a => a.Digest, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return document with
|
||||
{
|
||||
GeneratedAt = generatedAt,
|
||||
Artifacts = artifacts
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeDigest(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
var hash = sha.ComputeHash(bytes);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static SurfaceManifestArtifact NormalizeArtifact(SurfaceManifestArtifact artifact)
|
||||
{
|
||||
if (artifact.Metadata is null || artifact.Metadata.Count == 0)
|
||||
{
|
||||
return artifact;
|
||||
}
|
||||
|
||||
var sorted = artifact.Metadata
|
||||
.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
|
||||
.ToImmutableDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal);
|
||||
|
||||
return artifact with { Metadata = sorted };
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateTenantDirectories(string rootDirectory)
|
||||
{
|
||||
if (!Directory.Exists(rootDirectory))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var directory in Directory.EnumerateDirectories(rootDirectory))
|
||||
{
|
||||
yield return directory;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Sanitize(string value)
|
||||
=> string.IsNullOrWhiteSpace(value)
|
||||
? "default"
|
||||
: value.Replace('/', '_').Replace('\\', '_');
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace StellaOps.Scanner.Surface.FS;
|
||||
|
||||
/// <summary>
|
||||
/// Provides read access to published surface manifests.
|
||||
/// </summary>
|
||||
public interface ISurfaceManifestReader
|
||||
{
|
||||
Task<SurfaceManifestDocument?> TryGetByDigestAsync(
|
||||
string manifestDigest,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<SurfaceManifestDocument?> TryGetByUriAsync(
|
||||
string manifestUri,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace StellaOps.Scanner.Surface.FS;
|
||||
|
||||
/// <summary>
|
||||
/// Publishes manifest documents to the configured manifest store.
|
||||
/// </summary>
|
||||
public interface ISurfaceManifestWriter
|
||||
{
|
||||
Task<SurfaceManifestPublishResult> PublishAsync(
|
||||
SurfaceManifestDocument document,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
@@ -7,7 +8,8 @@ namespace StellaOps.Scanner.Surface.FS;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
private const string ConfigurationSection = "Surface:Cache";
|
||||
private const string CacheConfigurationSection = "Surface:Cache";
|
||||
private const string ManifestConfigurationSection = "Surface:Manifest";
|
||||
|
||||
public static IServiceCollection AddSurfaceFileCache(
|
||||
this IServiceCollection services,
|
||||
@@ -19,7 +21,7 @@ public static class ServiceCollectionExtensions
|
||||
}
|
||||
|
||||
services.AddOptions<SurfaceCacheOptions>()
|
||||
.BindConfiguration(ConfigurationSection);
|
||||
.BindConfiguration(CacheConfigurationSection);
|
||||
|
||||
if (configure is not null)
|
||||
{
|
||||
@@ -31,6 +33,33 @@ public static class ServiceCollectionExtensions
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddSurfaceManifestStore(
|
||||
this IServiceCollection services,
|
||||
Action<SurfaceManifestStoreOptions>? configure = null)
|
||||
{
|
||||
if (services is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(services));
|
||||
}
|
||||
|
||||
services.AddOptions<SurfaceManifestStoreOptions>()
|
||||
.BindConfiguration(ManifestConfigurationSection);
|
||||
|
||||
if (configure is not null)
|
||||
{
|
||||
services.Configure(configure);
|
||||
}
|
||||
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IValidateOptions<SurfaceManifestStoreOptions>, SurfaceManifestStoreOptionsValidator>());
|
||||
|
||||
services.TryAddSingleton<FileSurfaceManifestStore>();
|
||||
services.TryAddSingleton<ISurfaceManifestWriter>(sp => sp.GetRequiredService<FileSurfaceManifestStore>());
|
||||
services.TryAddSingleton<ISurfaceManifestReader>(sp => sp.GetRequiredService<FileSurfaceManifestStore>());
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private sealed class SurfaceCacheOptionsValidator : IValidateOptions<SurfaceCacheOptions>
|
||||
{
|
||||
public ValidateOptionsResult Validate(string? name, SurfaceCacheOptions options)
|
||||
@@ -56,4 +85,32 @@ public static class ServiceCollectionExtensions
|
||||
return ValidateOptionsResult.Success;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SurfaceManifestStoreOptionsValidator : IValidateOptions<SurfaceManifestStoreOptions>
|
||||
{
|
||||
public ValidateOptionsResult Validate(string? name, SurfaceManifestStoreOptions options)
|
||||
{
|
||||
if (options is null)
|
||||
{
|
||||
return ValidateOptionsResult.Fail("Options cannot be null.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.Scheme))
|
||||
{
|
||||
return ValidateOptionsResult.Fail("Manifest URI scheme cannot be empty.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.Bucket))
|
||||
{
|
||||
return ValidateOptionsResult.Fail("Manifest bucket cannot be empty.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.Prefix))
|
||||
{
|
||||
return ValidateOptionsResult.Fail("Manifest prefix cannot be empty.");
|
||||
}
|
||||
|
||||
return ValidateOptionsResult.Success;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0-preview.7.25380.108" />
|
||||
<PackageReference Include="System.Collections.Immutable" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -30,7 +30,7 @@ public sealed record SurfaceManifestDocument
|
||||
|
||||
[JsonPropertyName("generatedAt")]
|
||||
public DateTimeOffset GeneratedAt { get; init; }
|
||||
= DateTimeOffset.UtcNow;
|
||||
= DateTimeOffset.MinValue;
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.FS;
|
||||
|
||||
internal sealed class SurfaceManifestPathBuilder
|
||||
{
|
||||
private readonly SurfaceManifestStoreOptions _storeOptions;
|
||||
private readonly string _root;
|
||||
|
||||
public SurfaceManifestPathBuilder(
|
||||
SurfaceCacheOptions cacheOptions,
|
||||
SurfaceManifestStoreOptions storeOptions)
|
||||
{
|
||||
if (cacheOptions is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(cacheOptions));
|
||||
}
|
||||
|
||||
_storeOptions = storeOptions ?? throw new ArgumentNullException(nameof(storeOptions));
|
||||
_root = storeOptions.ResolveRoot(cacheOptions);
|
||||
}
|
||||
|
||||
public string BuildManifestPath(string tenant, string digestHex)
|
||||
{
|
||||
var sanitizedTenant = Sanitize(tenant);
|
||||
return Path.Combine(_root, sanitizedTenant, digestHex[..2], digestHex[2..4], $"{digestHex}.json");
|
||||
}
|
||||
|
||||
public string BuildManifestUri(string tenant, string digestHex)
|
||||
{
|
||||
var tenantSegment = Sanitize(tenant);
|
||||
return _storeOptions.ToUri(tenantSegment, digestHex);
|
||||
}
|
||||
|
||||
public static ParsedManifestPointer ParseUri(string manifestUri)
|
||||
{
|
||||
if (!Uri.TryCreate(manifestUri, UriKind.Absolute, out var uri))
|
||||
{
|
||||
throw new ArgumentException("Manifest URI must be an absolute URI.", nameof(manifestUri));
|
||||
}
|
||||
|
||||
var segments = uri.AbsolutePath.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (segments.Length < 4)
|
||||
{
|
||||
throw new ArgumentException("Manifest URI is missing expected segments.", nameof(manifestUri));
|
||||
}
|
||||
|
||||
var tenant = segments[^4];
|
||||
var digestFile = segments[^1];
|
||||
|
||||
if (!digestFile.EndsWith(".json", StringComparison.Ordinal))
|
||||
{
|
||||
throw new ArgumentException("Manifest URI must end with a JSON file.", nameof(manifestUri));
|
||||
}
|
||||
|
||||
var digestHex = digestFile[..^5];
|
||||
return new ParsedManifestPointer(uri.Scheme, tenant, digestHex);
|
||||
}
|
||||
|
||||
public static string EnsureSha256Digest(string manifestDigest)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(manifestDigest))
|
||||
{
|
||||
throw new ArgumentException("Digest cannot be null or empty.", nameof(manifestDigest));
|
||||
}
|
||||
|
||||
const string prefix = "sha256:";
|
||||
if (!manifestDigest.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new ArgumentException("Only sha256 digests are supported.", nameof(manifestDigest));
|
||||
}
|
||||
|
||||
return manifestDigest[prefix.Length..].ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string Sanitize(string value)
|
||||
=> string.IsNullOrWhiteSpace(value)
|
||||
? "default"
|
||||
: value.Replace('/', '_').Replace('\\', '_');
|
||||
|
||||
internal readonly record struct ParsedManifestPointer(string Scheme, string TenantSegment, string DigestHex);
|
||||
|
||||
public string RootDirectory => _root;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.FS;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration settings for the manifest store.
|
||||
/// </summary>
|
||||
public sealed class SurfaceManifestStoreOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the root directory used to persist manifest payloads. When <c>null</c>,
|
||||
/// the value falls back to the surface cache root.
|
||||
/// </summary>
|
||||
public string? RootDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the URI scheme emitted for manifest pointers.
|
||||
/// </summary>
|
||||
public string Scheme { get; set; } = "cas";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the bucket name portion of manifest URIs.
|
||||
/// </summary>
|
||||
public string Bucket { get; set; } = "surface-cache";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the prefix used before tenant segments within manifest URIs.
|
||||
/// </summary>
|
||||
public string Prefix { get; set; } = "manifests";
|
||||
|
||||
internal string ResolveRoot(SurfaceCacheOptions cacheOptions)
|
||||
{
|
||||
var root = RootDirectory;
|
||||
if (string.IsNullOrWhiteSpace(root))
|
||||
{
|
||||
root = Path.Combine(cacheOptions.ResolveRoot(), "manifests");
|
||||
}
|
||||
|
||||
return root!;
|
||||
}
|
||||
|
||||
internal string ToUri(string tenantSegment, string digestHex)
|
||||
=> $"{Scheme}://{Bucket}/{Prefix}/{tenantSegment}/{digestHex[..2]}/{digestHex[2..4]}/{digestHex}.json";
|
||||
}
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| SURFACE-FS-01 | DOING (2025-11-02) | Scanner Guild, Zastava Guild | ARCH-SURFACE-EPIC | Author `docs/modules/scanner/design/surface-fs.md` defining cache layout, pointer schema, tenancy, and offline handling. | Spec merged; reviewers from Scanner/Zastava sign off; component map cross-link drafted. |
|
||||
| SURFACE-FS-02 | DOING (2025-11-02) | Scanner Guild | SURFACE-FS-01 | Implement `StellaOps.Scanner.Surface.FS` core abstractions (writer, reader, manifest models) with deterministic serialization + unit tests. | Library compiles; tests pass; XML docs cover public types. |
|
||||
| SURFACE-FS-01 | DONE (2025-11-07) | Scanner Guild, Zastava Guild | ARCH-SURFACE-EPIC | Author `docs/modules/scanner/design/surface-fs.md` defining cache layout, pointer schema, tenancy, and offline handling. | Spec merged; reviewers from Scanner/Zastava sign off; component map cross-link drafted. |
|
||||
| SURFACE-FS-02 | DONE (2025-11-07) | Scanner Guild | SURFACE-FS-01 | Implement `StellaOps.Scanner.Surface.FS` core abstractions (writer, reader, manifest models) with deterministic serialization + unit tests. | Library compiles; tests pass; XML docs cover public types. |
|
||||
| SURFACE-FS-03 | TODO | Scanner Guild | SURFACE-FS-02 | Integrate Surface.FS writer into Scanner Worker analyzer pipeline to persist layer + entry-trace fragments. | Worker produces cache entries in integration tests; observability counters emitted. |
|
||||
| SURFACE-FS-04 | TODO | Zastava Guild | SURFACE-FS-02 | Integrate Surface.FS reader into Zastava Observer runtime drift loop. | Observer validates runtime artefacts via cache; regression tests updated. |
|
||||
| SURFACE-FS-05 | TODO | Scanner Guild, Scheduler Guild | SURFACE-FS-03 | Expose Surface.FS pointers via Scanner WebService reports and coordinate rescan planning with Scheduler. | API contracts updated; Scheduler consumes pointers; docs refreshed. |
|
||||
| SURFACE-FS-06 | TODO | Docs Guild | SURFACE-FS-02..05 | Update scanner-engine guide and offline kit docs with Surface.FS workflow. | Docs merged; offline kit manifests include cache bundles. |
|
||||
|
||||
> 2025-11-07: Delivered file-backed manifest store with deterministic pointer layout, updated design doc with component map cross-link, and added regression tests (note: `dotnet test` hangs in current CLI; rerun once environment supports Linux dotnet output).
|
||||
|
||||
Reference in New Issue
Block a user