diff --git a/memex/aspire/Memex.Database.Migration/Migrations/V39_AddSyncBehaviorColumn.cs b/memex/aspire/Memex.Database.Migration/Migrations/V39_AddSyncBehaviorColumn.cs new file mode 100644 index 0000000000..86f232bcae --- /dev/null +++ b/memex/aspire/Memex.Database.Migration/Migrations/V39_AddSyncBehaviorColumn.cs @@ -0,0 +1,45 @@ +using Microsoft.Extensions.Logging; + +namespace Memex.Database.Migration.Migrations; + +/// +/// Adds the sync_behavior column to every existing mesh_nodes table. +/// +/// Before this, MeshNode.SyncBehavior — the static-repo "Not synced" decouple +/// claim — had no Postgres column: PostgreSqlStorageAdapter dropped it on write and +/// defaulted it to on read. So a decoupled +/// partition silently re-coupled on the next restart and the static-repo import re-clobbered +/// admin-managed content — e.g. a customer's Provider/Anthropic API key reverting to the +/// shipped default. The schema initializer now defines the column on fresh schemas; this repair +/// adds it to schemas that already exist (public + auth + every partition). +/// +/// Idempotent: ADD COLUMN IF NOT EXISTS across every schema that has a +/// mesh_nodes table, in one dynamic-SQL pass on the runner connection. +/// +public sealed class V39_AddSyncBehaviorColumn : IMigration +{ + public int Version => 39; + public string Description => "Add sync_behavior column to mesh_nodes (persist the partition decouple claim)"; + + public async Task RunAsync(MigrationContext ctx) + { + await using var cmd = ctx.DataSource.CreateCommand(""" + DO $$ + DECLARE r record; + BEGIN + FOR r IN + SELECT table_schema + FROM information_schema.tables + WHERE table_name = 'mesh_nodes' + LOOP + EXECUTE format( + 'ALTER TABLE %I.mesh_nodes ADD COLUMN IF NOT EXISTS sync_behavior SMALLINT NOT NULL DEFAULT 0', + r.table_schema); + END LOOP; + END $$; + """); + await cmd.ExecuteNonQueryAsync(); + ctx.Logger.LogInformation( + "Repair v39: ensured sync_behavior column on every mesh_nodes table (decouple claim now persists)."); + } +} diff --git a/memex/aspire/Memex.Database.Migration/Program.cs b/memex/aspire/Memex.Database.Migration/Program.cs index 563ec16957..24df5a2c22 100644 --- a/memex/aspire/Memex.Database.Migration/Program.cs +++ b/memex/aspire/Memex.Database.Migration/Program.cs @@ -149,6 +149,7 @@ new V36_MoveAgentsToPerPartitionAgentNamespace(), new V37_MoveAgentsToAgentNamespaceBySchema(), new V38_DropLegacyProviderSchema(), + new V39_AddSyncBehaviorColumn(), }; var ctx = new MigrationContext(dataSource, connectionString, options, logger, initResult.IsFreshDb); diff --git a/src/MeshWeaver.AI/Data/Skill/provider-keys.md b/src/MeshWeaver.AI/Data/Skill/provider-keys.md new file mode 100644 index 0000000000..5be698ce50 --- /dev/null +++ b/src/MeshWeaver.AI/Data/Skill/provider-keys.md @@ -0,0 +1,167 @@ +--- +nodeType: Skill +name: /provider-keys +description: Administer AI provider API keys (Anthropic, OpenAI, Azure Foundry) in MeshWeaver — set/rotate a key the framework way (encrypted at rest), the "bring your own key" decouple workflow so it never silently reverts to the shared default, the master key, and the AKS Key Vault → CSI → node layering. +icon: Key +category: Skills +order: 10 +--- + +You are administering **AI provider API keys** for a MeshWeaver deployment. Keys live in +**layers**, and the single most common failure is a customer key **silently reverting to +the shared default** after a deploy. Get the layers and the decouple step right, or it +will revert again. + +# The model — where a key actually lives + +A provider is the mesh node **`Provider/`** (e.g. `Provider/Anthropic`, +`Provider/OpenAI`), `nodeType: ModelProvider`, `Content` of type `ModelProviderConfiguration`: + +```jsonc +{ "provider": "Anthropic", + "apiKey": "enc:v1:…", // encrypted at rest — see §the master key + "endpoint": "https://api.anthropic.com/v1/messages", // MUST match the key's account (see below) + "label": "Anthropic", + "models": ["claude-opus-4-8", "claude-sonnet-4-6", "claude-haiku-4-5"] } +``` + +Three layers feed it; **the node is the runtime authority** — the agent factory +(`AzureClaudeChatClientAgentFactory`) reads the node's `apiKey`/`endpoint` first and only +falls back to config: + +1. **The `Provider/` node** — what the portal actually calls the model with. Authoritative. +2. **The env-var seed** — `Anthropic:ApiKey` / `Anthropic:Endpoint` (env `Anthropic__ApiKey`). + `BuiltInLanguageModelProvider` seeds the node from this **once** (create-if-absent), then + the admin owns it. On a re-sync the seed re-stamps the node (see §decouple). +3. **On AKS, the seed itself comes from Key Vault** → CSI → the pod's `Anthropic__ApiKey` env + (see §AKS layering). + +**Endpoint and key type must match.** A direct Anthropic key (`sk-ant-…`) only works against +`https://api.anthropic.com/v1/messages`. An **Azure Foundry**–routed key works against +`https://.services.ai.azure.com/anthropic/`. Installing a direct key while the +endpoint still points at Foundry (or vice-versa) → 401. When you change the key, check the +endpoint in the same edit. + +# Set or rotate a key — the framework way (encrypted at rest) + +**GUI (preferred):** open `Provider/` → **Enter Key** → paste the plaintext key → **Save key**. +That path (`ModelProviderLayoutAreas.SaveKey`) does exactly this — replicate it if you script it: + +```csharp +var protector = hub.ServiceProvider.GetService(); +var stored = protector is null ? newKey : protector.Protect(newKey); // plaintext → enc:v1:… +hub.GetWorkspace().GetMeshNodeStream("Provider/Anthropic") + .Update(n => n.Content is ModelProviderConfiguration cfg + ? n with { Content = cfg with { ApiKey = stored } } // (+ Endpoint = … if changing it) + : n) + .Subscribe(updated => hub.Post(new SaveMeshNodeRequest(updated), + o => o.WithTarget(new Address("Provider/Anthropic"))), + ex => logger.LogWarning(ex, "Saving provider key failed")); +``` + +Notes that keep it correct, not a band-aid: +- **Always `Protect()` first.** A raw key written without encryption *functions* (the read path + passes untagged values through) but sits **plaintext at rest** in Postgres. Don't. +- The write runs under **the caller's identity** so RLS gates it — **no `ImpersonateAsSystem`**. +- The trailing `SaveMeshNodeRequest` force-persists: sync-driven nodes don't always fire the + per-node save subscription, so the `Update` alone can be lost on a synced partition. +- **🚨 MCP `update`/`patch` does NOT encrypt.** Writing `apiKey` via the raw MCP tools stores + plaintext. Use the GUI **Enter Key** action or `execute_script` that calls + `IProviderKeyProtector.Protect` — never a raw `patch` of `apiKey`. + +# 🚨 Bring your own key → DECOUPLE, or it reverts + +This is the workflow for any key we ship with a shared default that a customer replaces: + +1. **We initialize.** `Provider/` ships **Synced**, seeded with the shared default key + (e.g. the Azure Foundry key + Foundry endpoint). Works out of the box. +2. **You bring your own.** Enter the customer's key (and matching endpoint) via the GUI. +3. **You decouple.** Set the **`Provider` partition to "Not synced"** — *this step is what makes + the key permanent.* **Skip it and the next deploy/import re-seeds the node back to our + default** — the exact incident where `Provider/Anthropic` reverted to the shared Azure key. + +Decouple sets the partition root's `SyncBehavior` to `ExcludeThisAndChildren` (importer skips +root + every child): + +- **GUI:** Admin → Partitions → `Provider` → Sync status → **Not synced** (or per-node **Stop Sync** + on `Provider/Anthropic` for finer scope — `StopSyncLayoutArea`). +- **`SyncBehavior`** values: `Include` (synced, default) · `ExcludeThisOnly` · `ExcludeThisAndChildren` + ("Not synced"). +- The importer reads this claim **authoritatively** (`StaticRepoImporter.ReadClaimedRoots`, + `GetMeshNodeStream` not a query snapshot) so a just-set decouple is honoured before the next pass. +- **Never "remove the source" to stop sync** — that orphans the partition and **deletes it + entirely** (keys and all). "Not synced" is the safe, reversible control. + +> A still-**Synced** `Provider` partition + a deploy/restart = key silently back to our default. +> If you've checked everything and the key keeps reverting, the partition is not decoupled. + +# Validate BEFORE you install (don't trust a stale key) + +A revoked/rotated key fails closed with **401**, breaking every chat round. Check it first — +cheap, no token spend: + +```bash +curl -sS -o /dev/null -w "%{http_code}\n" https://api.anthropic.com/v1/models \ + -H "x-api-key: $KEY" -H "anthropic-version: 2023-06-01" # 200 = live, 401 = revoked/rotated +``` + +If 401, the key is dead — do **not** install it (it would replace a working key with a broken +one). Get a fresh key from the customer. The same applies to recovering an old key from a Key +Vault version: an earlier version may be the original customer key, but **validate it** — it may +have been rotated on the provider side since. + +# The master key (encryption at rest) + +`apiKey` is stored as `enc:v1:{base64(nonce|ciphertext|tag)}` — AES-256-GCM via +`IProviderKeyProtector`. The master key comes from config **`Ai:KeyProtection:MasterKey`** +(env `Ai__KeyProtection__MasterKey`); a **null** master key disables encryption (passthrough — +keys stored plaintext). A fresh random nonce per encrypt means the same key encrypts to +different ciphertext each time — **the stored blob is not a fingerprint** of the key; don't +compare blobs to tell two keys apart. + +- **On AKS** the master key is KV secret `-Ai-KeyProtection-MasterKey`. +- **🚨 On a DB migration/restore, REUSE the source env's master key.** A fresh master key over a + database that already holds `enc:v1:` values makes every stored provider key **undecryptable** + (decrypt returns null → providers silently keyless). Only mint a fresh master key for an + **empty** database. + +# AKS — Key Vault → CSI → env layering + +On AKS the env-var seed is sourced from Key Vault `Systemorph`, synced by a CSI +`SecretProviderClass` (`-portal-ai-secrets`) into a k8s secret, then loaded via `envFrom`: + +``` +KV Systemorph/-Anthropic-ApiKey ──CSI──▶ secret -portal-ai-secrets[Anthropic__ApiKey] ──envFrom──▶ Anthropic__ApiKey +``` + +- **The CSI secret must be LAST in the container's `envFrom`** — later sources win, so it + overrides the chart's `memex-portal-config` / `memex-portal-secrets` defaults. +- **🚨 Never bake a provider key into `values..yaml`'s `config.*` section** — that lands it + **plaintext in the `memex-portal-config` ConfigMap** (not even a Secret). Keep keys in the KV/CSI + secret only. +- **Rotate the KV-sourced key:** `az keyvault secret set --vault-name Systemorph --name + -Anthropic-ApiKey --value ` (creates a new version) → `kubectl -n rollout + restart deployment/memex-portal-deployment` (CSI re-reads on the next mount). This only changes + the **seed**; if the `Provider` partition is decoupled, also update the **node** (Enter Key) — + the decoupled node won't pick up the new seed. +- **When restaging an env, the Anthropic secret is easy to get wrong** — `OnboardingNewEnvironment` + doesn't list it, so a re-run can paste the shared Foundry key over the customer's. Set + `-Anthropic-ApiKey` to the **customer's** key deliberately. + +# Troubleshooting: "the key reverted to our own" + +1. **Read the node** — `get @Provider/Anthropic`. If `endpoint` is the Foundry URL + (`…services.ai.azure.com/anthropic/`), it's on our shared key, not the customer's. +2. **Is the partition decoupled?** `get @Provider` → if `lastModifiedBy: system-security` / + `SyncBehavior: Include`, it's still synced and will keep re-seeding. **Decouple it.** +3. **Check the seed.** On AKS compare the KV `-Anthropic-ApiKey` value against + `AzureFoundry-ApiKey` — if equal, the seed was overwritten with our shared key; reset KV to the + customer's key (validate first). +4. **Fix order:** validate the customer key → Enter Key on the node (+ endpoint) → decouple the + `Provider` partition → restore the KV seed → scrub any plaintext key from the ConfigMap. + +# Related + +- [Managing Partition Sync](/Doc/Architecture/PartitionSyncGuide) · [Static-Repo Import](/Doc/Architecture/StaticRepoImport) +- [Onboarding a New Environment](/Doc/Architecture/OnboardingNewEnvironment) · [Memex Cloud Deployment](/Doc/Architecture/MemexCloudDeployment) +- [Access Control](/Doc/Architecture/AccessControl) (platform admin = who may administer keys) diff --git a/src/MeshWeaver.Hosting.PostgreSql/PgMeshNodeReader.cs b/src/MeshWeaver.Hosting.PostgreSql/PgMeshNodeReader.cs new file mode 100644 index 0000000000..45c5fbef7c --- /dev/null +++ b/src/MeshWeaver.Hosting.PostgreSql/PgMeshNodeReader.cs @@ -0,0 +1,31 @@ +using MeshWeaver.Mesh; +using Npgsql; + +namespace MeshWeaver.Hosting.PostgreSql; + +/// +/// Shared row-reading helpers for materializing a from a +/// Postgres result set across the storage adapter and the cross-schema query provider. +/// +internal static class PgMeshNodeReader +{ + /// + /// Reads the node-level (the static-repo "Not synced" + /// decouple claim) from a result row. The sync_behavior column lives only on + /// mesh_nodes — the sole decouplable table — so reads from satellite tables and + /// from rows written before the column existed won't surface it; those default to + /// (fully synced), the column's DEFAULT 0. + /// Defensive lookup (scan field names rather than GetOrdinal) keeps every existing + /// SELECT that omits the column working unchanged. + /// + internal static SyncBehavior ReadSyncBehavior(NpgsqlDataReader reader) + { + for (var i = 0; i < reader.FieldCount; i++) + { + if (!string.Equals(reader.GetName(i), "sync_behavior", StringComparison.Ordinal)) + continue; + return reader.IsDBNull(i) ? SyncBehavior.Include : (SyncBehavior)reader.GetInt16(i); + } + return SyncBehavior.Include; + } +} diff --git a/src/MeshWeaver.Hosting.PostgreSql/PostgreSqlCrossSchemaQueryProvider.cs b/src/MeshWeaver.Hosting.PostgreSql/PostgreSqlCrossSchemaQueryProvider.cs index c0c100565a..769d882ef2 100644 --- a/src/MeshWeaver.Hosting.PostgreSql/PostgreSqlCrossSchemaQueryProvider.cs +++ b/src/MeshWeaver.Hosting.PostgreSql/PostgreSqlCrossSchemaQueryProvider.cs @@ -422,6 +422,7 @@ private MeshNode ReadMeshNode(NpgsqlDataReader reader, JsonSerializerOptions opt LastModified = new DateTimeOffset(reader.GetDateTime(reader.GetOrdinal("last_modified")), TimeSpan.Zero), Version = reader.GetInt64(reader.GetOrdinal("version")), State = (MeshNodeState)reader.GetInt16(reader.GetOrdinal("state")), + SyncBehavior = PgMeshNodeReader.ReadSyncBehavior(reader), Content = content, DesiredId = reader.IsDBNull(reader.GetOrdinal("desired_id")) ? null : reader.GetString(reader.GetOrdinal("desired_id")), MainNode = reader.IsDBNull(reader.GetOrdinal("main_node")) diff --git a/src/MeshWeaver.Hosting.PostgreSql/PostgreSqlSchemaInitializer.cs b/src/MeshWeaver.Hosting.PostgreSql/PostgreSqlSchemaInitializer.cs index 773e8edf0b..831ad43dc1 100644 --- a/src/MeshWeaver.Hosting.PostgreSql/PostgreSqlSchemaInitializer.cs +++ b/src/MeshWeaver.Hosting.PostgreSql/PostgreSqlSchemaInitializer.cs @@ -630,6 +630,10 @@ path TEXT GENERATED ALWAYS AS ( last_modified TIMESTAMPTZ NOT NULL DEFAULT NOW(), version BIGINT NOT NULL DEFAULT 0, state SMALLINT NOT NULL DEFAULT 0, + -- node-level static-repo sync claim (0=Include, 1=ExcludeThisOnly, + -- 2=ExcludeThisAndChildren). Persists the "Not synced" decouple so an + -- admin-claimed node/partition survives restart + the next import. + sync_behavior SMALLINT NOT NULL DEFAULT 0, content JSONB, desired_id TEXT, main_node TEXT, @@ -930,6 +934,10 @@ path TEXT GENERATED ALWAYS AS ( last_modified TIMESTAMPTZ NOT NULL DEFAULT NOW(), version BIGINT NOT NULL DEFAULT 0, state SMALLINT NOT NULL DEFAULT 0, + -- node-level static-repo sync claim (0=Include, 1=ExcludeThisOnly, + -- 2=ExcludeThisAndChildren). Persists the "Not synced" decouple so an + -- admin-claimed node/partition survives restart + the next import. + sync_behavior SMALLINT NOT NULL DEFAULT 0, content JSONB, desired_id TEXT, main_node TEXT, @@ -1301,6 +1309,10 @@ path TEXT GENERATED ALWAYS AS ( last_modified TIMESTAMPTZ NOT NULL DEFAULT NOW(), version BIGINT NOT NULL DEFAULT 0, state SMALLINT NOT NULL DEFAULT 0, + -- node-level static-repo sync claim (0=Include, 1=ExcludeThisOnly, + -- 2=ExcludeThisAndChildren). Persists the "Not synced" decouple so an + -- admin-claimed node/partition survives restart + the next import. + sync_behavior SMALLINT NOT NULL DEFAULT 0, content JSONB, desired_id TEXT, main_node TEXT, @@ -1607,6 +1619,10 @@ path TEXT GENERATED ALWAYS AS ( last_modified TIMESTAMPTZ NOT NULL DEFAULT NOW(), version BIGINT NOT NULL DEFAULT 0, state SMALLINT NOT NULL DEFAULT 0, + -- node-level static-repo sync claim (0=Include, 1=ExcludeThisOnly, + -- 2=ExcludeThisAndChildren). Persists the "Not synced" decouple so an + -- admin-claimed node/partition survives restart + the next import. + sync_behavior SMALLINT NOT NULL DEFAULT 0, content JSONB, desired_id TEXT, main_node TEXT, @@ -1968,6 +1984,10 @@ path TEXT GENERATED ALWAYS AS ( last_modified TIMESTAMPTZ NOT NULL DEFAULT NOW(), version BIGINT NOT NULL DEFAULT 0, state SMALLINT NOT NULL DEFAULT 0, + -- node-level static-repo sync claim (0=Include, 1=ExcludeThisOnly, + -- 2=ExcludeThisAndChildren). Persists the "Not synced" decouple so an + -- admin-claimed node/partition survives restart + the next import. + sync_behavior SMALLINT NOT NULL DEFAULT 0, content JSONB, desired_id TEXT, main_node TEXT, diff --git a/src/MeshWeaver.Hosting.PostgreSql/PostgreSqlStorageAdapter.cs b/src/MeshWeaver.Hosting.PostgreSql/PostgreSqlStorageAdapter.cs index 98ac349d06..1af1b565e7 100644 --- a/src/MeshWeaver.Hosting.PostgreSql/PostgreSqlStorageAdapter.cs +++ b/src/MeshWeaver.Hosting.PostgreSql/PostgreSqlStorageAdapter.cs @@ -156,6 +156,14 @@ private string ResolveTable(string path, string? nodeType = null) return QualifyTable(table); } + // Projection for the node-level sync claim: the real column when reading mesh_nodes (the + // only decouplable table), else the Include (0) default so single-table reads and UNION + // branches over satellite tables — which don't carry the column — keep a stable shape. + private static string SyncBehaviorCol(string qualifiedTable) => + qualifiedTable.Contains("mesh_nodes", StringComparison.OrdinalIgnoreCase) + ? "sync_behavior" + : "0 AS sync_behavior"; + private static string NormalizePath(string? path) => path?.Trim('/') ?? ""; @@ -203,7 +211,7 @@ private static bool IsUndefinedTable(Exception ex) { await using var cmd = _dataSource.CreateCommand( $"SELECT id, namespace, name, description, node_type, category, icon, display_order, " + - $"last_modified, version, state, content, desired_id, main_node " + + $"last_modified, version, state, content, desired_id, main_node, {SyncBehaviorCol(table)} " + $"FROM {table} WHERE namespace = $1 AND id = $2"); cmd.Parameters.AddWithValue(ns); cmd.Parameters.AddWithValue(id); @@ -282,7 +290,7 @@ private async IAsyncEnumerable ReadManyAsyncCore( Enumerable.Range(2, ids.Length).Select(i => $"${i}")); await using var cmd = _dataSource.CreateCommand( $"SELECT id, namespace, name, description, node_type, category, icon, display_order, " + - $"last_modified, version, state, content, desired_id, main_node " + + $"last_modified, version, state, content, desired_id, main_node, {SyncBehaviorCol(table)} " + $"FROM {table} WHERE namespace = $1 AND id IN ({placeholders})"); cmd.Parameters.AddWithValue(ns); foreach (var id in ids) @@ -330,11 +338,17 @@ private async Task WriteAsyncCore(MeshNode node, JsonSerializerOptions options, : null; var table = ResolveTable(node.Path, node.NodeType); + // sync_behavior lives only on mesh_nodes (the sole decouplable table); satellite + // tables don't carry it, so write/update it only when targeting mesh_nodes. + var writeSync = table.Contains("mesh_nodes", StringComparison.OrdinalIgnoreCase); + var syncInsertCol = writeSync ? ", sync_behavior" : ""; + var syncInsertVal = writeSync ? ", $16" : ""; + var syncUpdate = writeSync ? ",\n sync_behavior = EXCLUDED.sync_behavior" : ""; await using var cmd = _dataSource.CreateCommand( $""" INSERT INTO {table} (namespace, id, name, description, node_type, category, icon, display_order, - last_modified, version, state, content, desired_id, embedding, main_node) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12::jsonb, $13, $14, $15) + last_modified, version, state, content, desired_id, embedding, main_node{syncInsertCol}) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12::jsonb, $13, $14, $15{syncInsertVal}) ON CONFLICT (namespace, id) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description, @@ -348,7 +362,7 @@ ON CONFLICT (namespace, id) DO UPDATE SET content = EXCLUDED.content, desired_id = EXCLUDED.desired_id, embedding = EXCLUDED.embedding, - main_node = EXCLUDED.main_node + main_node = EXCLUDED.main_node{syncUpdate} """); cmd.Parameters.AddWithValue(ns); @@ -372,6 +386,10 @@ ON CONFLICT (namespace, id) DO UPDATE SET cmd.Parameters.AddWithValue(node.MainNode); + // $16 — only bound when the target is mesh_nodes (see writeSync above). + if (writeSync) + cmd.Parameters.AddWithValue((short)node.SyncBehavior); + await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); } @@ -481,7 +499,7 @@ private async Task ExistsAsyncCore(string path, CancellationToken ct) var table = ResolveTable(normalizedPath); await using var cmd = _dataSource.CreateCommand( $"SELECT id, namespace, name, description, node_type, category, icon, display_order, " + - $"last_modified, version, state, content, desired_id, main_node " + + $"last_modified, version, state, content, desired_id, main_node, {SyncBehaviorCol(table)} " + $"FROM {table} WHERE $1 = path OR $1 LIKE path || '/%' " + $"ORDER BY LENGTH(path) DESC LIMIT 1"); cmd.Parameters.AddWithValue(normalizedPath); @@ -541,7 +559,7 @@ private async Task ExistsAsyncCore(string path, CancellationToken ct) : $"\"{_schemaName}\".\"{t}\""; unionBranches.Add( $"SELECT id, namespace, name, description, node_type, category, icon, display_order, " + - $"last_modified, version, state, content, desired_id, main_node " + + $"last_modified, version, state, content, desired_id, main_node, {SyncBehaviorCol(qualified)} " + $"FROM {qualified} " + $"WHERE $1 = path OR $1 LIKE path || '/%'"); } @@ -1242,6 +1260,7 @@ private static MeshNode ReadMeshNode(NpgsqlDataReader reader, JsonSerializerOpti LastModified = new DateTimeOffset(reader.GetDateTime(reader.GetOrdinal("last_modified")), TimeSpan.Zero), Version = reader.GetInt64(reader.GetOrdinal("version")), State = (MeshNodeState)reader.GetInt16(reader.GetOrdinal("state")), + SyncBehavior = PgMeshNodeReader.ReadSyncBehavior(reader), Content = content, // Mirror the prerendered HTML onto the top-level field, like the FileSystem/Caching // adapters do (CachingStorageAdapter.MergeIndexMarkdownAsync). Consumers that render diff --git a/test/MeshWeaver.Hosting.PostgreSql.Test/SyncBehaviorPersistenceTests.cs b/test/MeshWeaver.Hosting.PostgreSql.Test/SyncBehaviorPersistenceTests.cs new file mode 100644 index 0000000000..38cf6798e7 --- /dev/null +++ b/test/MeshWeaver.Hosting.PostgreSql.Test/SyncBehaviorPersistenceTests.cs @@ -0,0 +1,83 @@ +using System.Reactive.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using MeshWeaver.Mesh; +using Xunit; + +namespace MeshWeaver.Hosting.PostgreSql.Test; + +/// +/// Regression for the atioz Provider/Anthropic key revert: a node's +/// — the static-repo "Not synced" decouple claim — must +/// survive a round-trip through the Postgres storage adapter. +/// +/// Before the sync_behavior column existed, the adapter persisted only the typed +/// content + a fixed set of metadata columns; SyncBehavior was dropped on write and the +/// row→node mapper never read it, so every reload defaulted to +/// . A partition an admin had decoupled therefore silently +/// re-coupled on the next restart, and the next static-repo import re-clobbered the +/// admin-managed key. (Sqlite was unaffected — it serializes the whole node as JSON — so this +/// only reproduces against Postgres.) +/// +[Collection("PostgreSql")] +public class SyncBehaviorPersistenceTests +{ + private readonly PostgreSqlFixture _fixture; + + public SyncBehaviorPersistenceTests(PostgreSqlFixture fixture) + { + _fixture = fixture; + } + + /// + /// A node written with reads back with the + /// same claim — the import-skip predicate (ReadClaimedRoots) can only honour a decouple + /// that storage actually round-trips. + /// + [Fact(Timeout = 60000)] + public async Task DecoupleClaim_SurvivesReloadFromStorage() + { + await _fixture.CleanDataAsync(); + var adapter = _fixture.StorageAdapter; + + var node = new MeshNode("DecoupledProvider") + { + Name = "Providers", + NodeType = "Space", + SyncBehavior = SyncBehavior.ExcludeThisAndChildren, + }; + + await adapter.Write(node, JsonSerializerOptions.Default).Should().Within(30.Seconds()).Emit(); + + var reloaded = await adapter + .Read("DecoupledProvider", JsonSerializerOptions.Default) + .Should().Within(30.Seconds()).Emit(); + + reloaded.Should().NotBeNull("the node was just written"); + reloaded!.SyncBehavior.Should().Be( + SyncBehavior.ExcludeThisAndChildren, + "the decouple claim must persist so the static-repo import skips the partition after a restart"); + } + + /// + /// A node that never set a sync claim reads back as — the + /// column default — so existing fully-synced content keeps receiving updates. + /// + [Fact(Timeout = 60000)] + public async Task UnclaimedNode_DefaultsToInclude() + { + await _fixture.CleanDataAsync(); + var adapter = _fixture.StorageAdapter; + + await adapter + .Write(new MeshNode("PlainNode") { Name = "plain", NodeType = "Markdown" }, JsonSerializerOptions.Default) + .Should().Within(30.Seconds()).Emit(); + + var reloaded = await adapter + .Read("PlainNode", JsonSerializerOptions.Default) + .Should().Within(30.Seconds()).Emit(); + + reloaded.Should().NotBeNull(); + reloaded!.SyncBehavior.Should().Be(SyncBehavior.Include); + } +}