diff --git a/src/Abstractions/CrestApps.Core.Abstractions/ExtensibleEntity.cs b/src/Abstractions/CrestApps.Core.Abstractions/ExtensibleEntity.cs
index d039c775..7fa245e6 100644
--- a/src/Abstractions/CrestApps.Core.Abstractions/ExtensibleEntity.cs
+++ b/src/Abstractions/CrestApps.Core.Abstractions/ExtensibleEntity.cs
@@ -1,3 +1,5 @@
+using System;
+using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace CrestApps.Core;
@@ -8,10 +10,9 @@ namespace CrestApps.Core;
public abstract class ExtensibleEntity
{
///
- /// Gets or sets the dictionary of additional properties that are not explicitly
- /// declared on the entity. Values are captured from JSON extension data during
- /// deserialization and are round-tripped back on serialization.
+ /// Gets or sets the dictionary of additional properties stored under the
+ /// Properties JSON object.
///
- [JsonExtensionData]
- public IDictionary Properties { get; set; } = new Dictionary();
+ [JsonConverter(typeof(ExtensibleEntityPropertiesJsonConverter))]
+ public IDictionary Properties { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase);
}
diff --git a/src/Abstractions/CrestApps.Core.Abstractions/ExtensibleEntityExtensions.cs b/src/Abstractions/CrestApps.Core.Abstractions/ExtensibleEntityExtensions.cs
index 44fa5c32..cfa27abf 100644
--- a/src/Abstractions/CrestApps.Core.Abstractions/ExtensibleEntityExtensions.cs
+++ b/src/Abstractions/CrestApps.Core.Abstractions/ExtensibleEntityExtensions.cs
@@ -44,8 +44,8 @@ public static T GetOrCreate(this ExtensibleEntity entity, JsonSerializerOptio
var key = typeof(T).Name;
return entity.Properties.TryGetValue(key, out var value)
- ? DeserializeValue(value, jsonSerializerOptions ?? _jsonOptions) ?? new T()
- : new T();
+ ? DeserializeValue(value, jsonSerializerOptions ?? _jsonOptions) ?? new T()
+ : new T();
}
///
@@ -57,8 +57,8 @@ public static T Get(this ExtensibleEntity entity, string name, JsonSerializer
ArgumentException.ThrowIfNullOrEmpty(name);
return entity.Properties.TryGetValue(name, out var value)
- ? DeserializeValue(value, jsonSerializerOptions ?? _jsonOptions)
- : default;
+ ? DeserializeValue(value, jsonSerializerOptions ?? _jsonOptions)
+ : default;
}
///
diff --git a/src/Abstractions/CrestApps.Core.Abstractions/ExtensibleEntityPropertiesJsonConverter.cs b/src/Abstractions/CrestApps.Core.Abstractions/ExtensibleEntityPropertiesJsonConverter.cs
new file mode 100644
index 00000000..597d772c
--- /dev/null
+++ b/src/Abstractions/CrestApps.Core.Abstractions/ExtensibleEntityPropertiesJsonConverter.cs
@@ -0,0 +1,95 @@
+using System;
+using System.Collections.Generic;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Text.Json.Serialization;
+
+namespace CrestApps.Core;
+
+///
+/// Serializes and deserializes as a normal nested
+/// JSON object while keeping its values inside the property bag.
+///
+internal sealed class ExtensibleEntityPropertiesJsonConverter : JsonConverter>
+{
+ ///
+ /// Reads the property bag from a nested JSON object.
+ ///
+ /// The JSON reader.
+ /// The type to convert.
+ /// The serializer options.
+ /// The deserialized property bag.
+ public override IDictionary Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ if (reader.TokenType == JsonTokenType.Null)
+ {
+ return new Dictionary(StringComparer.OrdinalIgnoreCase);
+ }
+
+ var node = JsonNode.Parse(ref reader)?.AsObject();
+
+ if (node is null)
+ {
+ return new Dictionary(StringComparer.OrdinalIgnoreCase);
+ }
+
+ var properties = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var property in node)
+ {
+ properties[property.Key] = property.Value?.DeepClone();
+ }
+
+ return properties;
+ }
+
+ ///
+ /// Writes the property bag as a nested JSON object.
+ ///
+ /// The JSON writer.
+ /// The property bag value.
+ /// The serializer options.
+ public override void Write(Utf8JsonWriter writer, IDictionary value, JsonSerializerOptions options)
+ {
+ writer.WriteStartObject();
+
+ foreach (var property in value ?? new Dictionary(StringComparer.OrdinalIgnoreCase))
+ {
+ writer.WritePropertyName(property.Key);
+ WritePropertyValue(writer, property.Value);
+ }
+
+ writer.WriteEndObject();
+ }
+
+ ///
+ /// Writes a single property bag value using the extensible entity serializer options.
+ ///
+ /// The JSON writer.
+ /// The property value.
+ private static void WritePropertyValue(Utf8JsonWriter writer, object value)
+ {
+ if (value is null)
+ {
+ writer.WriteNullValue();
+
+ return;
+ }
+
+ if (value is JsonNode jsonNode)
+ {
+ jsonNode.WriteTo(writer, ExtensibleEntityExtensions.JsonSerializerOptions);
+
+ return;
+ }
+
+ if (value is JsonElement jsonElement)
+ {
+ jsonElement.WriteTo(writer);
+
+ return;
+ }
+
+ JsonSerializer.Serialize(writer, value, value.GetType(), ExtensibleEntityExtensions.JsonSerializerOptions);
+ }
+}
diff --git a/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md b/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md
index 4c17ca40..1148db26 100644
--- a/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md
+++ b/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md
@@ -67,3 +67,4 @@ description: Initial standalone release notes for the CrestApps.Core repository.
- treats valid post-session JSON with an empty `tasks` array as an explicit structured-result failure, persists that clearer error in `PostSessionResults`, and strengthens the shared post-session prompts so every configured task must still return a result even when no tool call is needed
- stops serializing redundant top-level post-session error fields on `PostSessionResult`, keeps attempt-specific failures in `AttemptHistory`, retries tool-enabled runs through structured recovery when the model returns invalid task entries such as blank names or blank values, and falls back to a no-tools structured retry when the tool path never actually invoked a tool
- refreshes site-settings-backed options through the standard `IOptionsMonitor<>` pipeline by documenting the minimal `IOptionsChangeTokenSource<>` pattern for custom hosts, and moves uploaded AI document vector indexing into a shared `DefaultAIDocumentIndexingService` so MVC and Blazor no longer carry duplicate sample-only indexer implementations
+- writes and reads `ExtensibleEntity.Properties` only through the nested `Properties` JSON object instead of flattening typed metadata onto the document root
diff --git a/src/CrestApps.Core.Docs/docs/core/extensible-entity.md b/src/CrestApps.Core.Docs/docs/core/extensible-entity.md
index 992abab2..2209b83b 100644
--- a/src/CrestApps.Core.Docs/docs/core/extensible-entity.md
+++ b/src/CrestApps.Core.Docs/docs/core/extensible-entity.md
@@ -13,6 +13,8 @@ description: Learn how to use ExtensibleEntity for dynamic property storage and
`ExtensibleEntity` provides a `Properties` dictionary that allows you to attach arbitrary strongly-typed data to any entity without changing its schema. This powers features like AI profile metadata, chat session annotations, and custom application data.
+Serialized entities keep extensible values inside the nested `Properties` object. Root-level flattened extension data is not treated as valid extensible metadata.
+
## Quick Reference
| Method | When to Use |
diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Admin/Settings/Index.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Admin/Settings/Index.razor
index 7a16a549..a18a548e 100644
--- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Admin/Settings/Index.razor
+++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Admin/Settings/Index.razor
@@ -347,6 +347,17 @@ else
The default number of matched chunks or documents to include when a profile does not override it.