fix(site-secrets): mirror heals same-revision local drift and isolates unreadable rows

Final review I1: a name already ledgered at the shipped revision is now read back every
pass; a missing, tombstoned or edited local row is healed through the mirror-owned re-stamp
path (bump, apply, read back, invalidate) before verify, instead of a local delete being
misreported as a KEK mismatch that blocked every later deploy. Drift logs Warning once per
occurrence. M4: row_json that cannot be parsed becomes a Failed entry for that name via the
new SiteStorageService.GetDeployedSecretsTolerantAsync; an unreadable removed row is still
honoured by name. M10: invalidator ctor doc corrected.
This commit is contained in:
Joseph Doherty
2026-09-28 21:08:34 -04:00
parent 9835ef8be3
commit 6f1787abe1
3 changed files with 277 additions and 27 deletions
@@ -780,9 +780,32 @@ public class SiteStorageService
/// <summary>
/// Returns every row in <c>deployed_secrets</c>, including soft-deleted (<c>is_removed</c>)
/// ones, with each row's <see cref="SecretArtifact"/> round-tripped from <c>row_json</c>.
/// Strict: a row whose <c>row_json</c> cannot be parsed throws. The per-node mirror reads
/// through <see cref="GetDeployedSecretsTolerantAsync"/> instead, so one unreadable row is
/// reported against its own name rather than aborting the whole reconcile pass.
/// </summary>
/// <returns>A task that resolves to every stored deployed-secret row.</returns>
public async Task<IReadOnlyList<DeployedSecretRow>> GetDeployedSecretsAsync()
{
var (rows, unreadable) = await GetDeployedSecretsTolerantAsync();
if (unreadable.Count > 0)
{
throw new InvalidOperationException(
$"deployed_secrets row '{unreadable[0].Name}' has invalid row_json ({unreadable[0].Error})");
}
return rows;
}
/// <summary>
/// Returns every row in <c>deployed_secrets</c> like <see cref="GetDeployedSecretsAsync"/>,
/// but never throws for a single bad row: a row whose <c>row_json</c> does not deserialize
/// is returned in <c>Unreadable</c> (name, removal flag and the parse error — never the
/// row's content) instead of failing the whole read (final review M4, per-row isolation).
/// </summary>
/// <returns>The parsed rows and the rows that could not be parsed.</returns>
public async Task<(IReadOnlyList<DeployedSecretRow> Rows, IReadOnlyList<UnreadableDeployedSecretRow> Unreadable)>
GetDeployedSecretsTolerantAsync()
{
await using var connection = OpenConnection();
@@ -790,22 +813,39 @@ public class SiteStorageService
command.CommandText = "SELECT name, row_json, is_removed, deployment_id FROM deployed_secrets";
var results = new List<DeployedSecretRow>();
var unreadable = new List<UnreadableDeployedSecretRow>();
await using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
var name = reader.GetString(0);
var rowJson = reader.GetString(1);
var secret = JsonSerializer.Deserialize<SecretArtifact>(rowJson)
?? throw new InvalidOperationException($"deployed_secrets row '{name}' has invalid row_json");
var isRemoved = reader.GetInt64(2) != 0;
results.Add(new DeployedSecretRow(
name,
secret,
reader.GetInt64(2) != 0,
reader.GetString(3)));
SecretArtifact? secret;
string? error = null;
try
{
secret = JsonSerializer.Deserialize<SecretArtifact>(reader.GetString(1));
if (secret is null)
{
error = "row_json deserialized to null";
}
}
catch (Exception ex) when (ex is JsonException or NotSupportedException or InvalidCastException)
{
secret = null;
error = $"{ex.GetType().Name}: {ex.Message}";
}
if (secret is null)
{
unreadable.Add(new UnreadableDeployedSecretRow(name, isRemoved, error!));
continue;
}
results.Add(new DeployedSecretRow(name, secret, isRemoved, reader.GetString(3)));
}
return results;
return (results, unreadable);
}
// ── Mirrored Secrets ledger (node-local only — deliberately NOT LocalDb-replicated) ──
@@ -1331,6 +1371,14 @@ public record NativeAlarmRow(
/// </summary>
public record DeployedSecretRow(string Name, SecretArtifact Secret, bool IsRemoved, string DeploymentId);
/// <summary>
/// A <c>deployed_secrets</c> row whose <c>row_json</c> could not be parsed — returned by
/// <see cref="SiteStorageService.GetDeployedSecretsTolerantAsync"/> so the mirror can report it
/// against its own name instead of aborting the pass. <paramref name="Error"/> is the parse error
/// (exception type and message), never the row's content.
/// </summary>
public record UnreadableDeployedSecretRow(string Name, bool IsRemoved, string Error);
/// <summary>
/// A single row from the site-local, node-local (NOT LocalDb-replicated) <c>mirrored_secrets</c>
/// ledger — the names this node has mirrored into its own secret store, and at what revision.