Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
1175893
chore: roll spec pin to v0.14.3 and centralize build config
Artifizer Sep 25, 2026
e4efe94
refactor(id): centralize GTS id constants and parsing
Artifizer Sep 25, 2026
065d45b
feat(store): add self-contained JSON Schema evaluation engine
Artifizer Sep 25, 2026
d71fd8a
refactor(store): introduce typed validation failures
Artifizer Sep 25, 2026
7b59991
refactor(store): decompose registry into focused services
Artifizer Sep 25, 2026
1cd7b55
refactor(server): split HTTP API into contracts, endpoints, and helpers
Artifizer Sep 25, 2026
0b16142
test(store): update CLI and validation tests to new services
Artifizer Sep 25, 2026
703be5e
test: guard ancestor cross-dialect $ref rejection
Artifizer Sep 26, 2026
6386e18
Fix x-gts-ref matching, $ref resolution, schema caching, and id parsing
Artifizer Sep 26, 2026
e4f0b00
fix(store): treat non-ref combinator branches as neutral in x-gts-ref
Artifizer Sep 26, 2026
2973b81
feat(server): honor ?validate and ?gts-ref-validation on POST /type-s…
Artifizer Sep 27, 2026
0e00422
feat(store): stage validate=true batch registration behind an overlay
Artifizer Sep 27, 2026
2ec5471
fix: fix formatting issues
Artifizer Sep 27, 2026
21af6b6
fix(store): make validate=true batch staging leak-, conflict- and dep…
Artifizer Sep 27, 2026
7ed2c35
fix(validation): bound schema-pattern match time to prevent ReDoS
Artifizer Sep 28, 2026
1981c32
Isolate staging per session and publish batches atomically
Artifizer Sep 28, 2026
f80b2c8
chore: update to gts-spec v0.14.3
Artifizer Sep 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gts-spec-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v0.14.2
v0.14.4
12 changes: 12 additions & 0 deletions Directory.Build.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<Project>

<!-- Settings shared by every project in the solution. Project files override
or extend these as needed (e.g. OutputType, RootNamespace, RollForward). -->
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Version>0.1.0</Version>
</PropertyGroup>

</Project>
3 changes: 0 additions & 3 deletions Gts.Application/Gts.Application.csproj
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>Gts.Application</RootNamespace>
</PropertyGroup>

Expand Down
137 changes: 107 additions & 30 deletions Gts.Application/GtsEntityOperations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@ namespace Gts.Application;
/// <summary>Adds entities to a registry with the same rules as the GTS HTTP API.</summary>
public static class GtsEntityOperations
{
public sealed record AddResult(bool Ok, string Id, string? SchemaId, bool IsSchema, string? Error);
public sealed record AddResult(bool Ok, string Id, string? SchemaId, bool IsSchema, string? Error, bool Conflict = false);

public static async Task<AddResult> TryAddAsync(
GtsRegistry registry,
JsonObject body,
bool validate,
GtsExtractOptions? extractOptions = null,
GtsRefValidationMode refValidationMode = GtsRefValidationModes.Default,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
Expand All @@ -24,7 +25,7 @@ public static async Task<AddResult> TryAddAsync(
if (!entity.IsSchema)
{
if (string.IsNullOrEmpty(entity.SelectedEntityField))
return new AddResult(false, "", null, false, "Instance must have an id field");
return new AddResult(false, "", null, false, "Unable to detect GTS ID in instance entity");
if (entity.GtsId is { IsInstance: true } instanceId && instanceId.Segments.Count == 1)
return new AddResult(false, instanceId.Id, null, false, "Single-segment instance IDs are not allowed");
}
Expand All @@ -39,11 +40,17 @@ public static async Task<AddResult> TryAddAsync(
var rawId = body.TryGetPropertyValue("$id", out var idn) && idn is JsonValue idValue && idValue.TryGetValue<string>(out var id)
? id
: null;
if (validate && !string.IsNullOrEmpty(rawId) && rawId.StartsWith("gts.", StringComparison.Ordinal) &&
!rawId.StartsWith("gts://", StringComparison.Ordinal))
if (validate && !string.IsNullOrEmpty(rawId) && rawId.StartsWith(GtsConstants.IdPrefix, StringComparison.Ordinal) &&
!rawId.StartsWith(GtsConstants.UriPrefix, StringComparison.Ordinal))
return new AddResult(false, "", null, true, "Schema $id must use gts:// URI format, not plain gts. prefix");
if (!TryGetSupportedDialect(body, out var dialectError))
if (!GtsTypeSchema.TryGetSupportedDialect(body, out _, out var dialectError))
return new AddResult(false, entity.GtsId?.Id ?? "", null, true, dialectError);
var keywordErrors = GtsSchemaKeywordValidator.Validate(body);
if (keywordErrors.Count > 0)
return new AddResult(false, entity.GtsId?.Id ?? "", null, true, string.Join("; ", keywordErrors));
var refErrors = GtsRefValidator.ValidatePatterns(body, entity.GtsId?.Id ?? "");
if (refErrors.Count > 0)
return new AddResult(false, entity.GtsId?.Id ?? "", null, true, "x-gts-ref validation failed: " + string.Join("; ", refErrors));
}

try
Expand All @@ -55,59 +62,129 @@ public static async Task<AddResult> TryAddAsync(
return new AddResult(false, "", null, entity.IsSchema, ex.Message);
}

GtsJsonEntity? existing = entity.GtsId is not null
? await registry.GetAsync(entity.GtsId).ConfigureAwait(false)
: !string.IsNullOrEmpty(extract.Id)
? await registry.GetByInstanceIdAsync(extract.Id).ConfigureAwait(false)
: null;
if (existing is not null)
{
if (JsonNode.DeepEquals(existing.Content, entity.Content) && !validate)
return new AddResult(true, extract.Id ?? entity.GtsId?.Id ?? "", entity.SchemaId, entity.IsSchema, null);
if (!JsonNode.DeepEquals(existing.Content, entity.Content))
return new AddResult(false, extract.Id ?? entity.GtsId?.Id ?? "", entity.SchemaId, entity.IsSchema,
"Entity already exists with different content", true);
}

if (entity.IsSchema && entity.GtsId is not null)
{
if (validate)
{
var schemaVr = await registry.ValidateSchemaAsync(entity.GtsId, entity.Content, cancellationToken)
var registered = await registry.SnapshotForReadAsync().ConfigureAwait(false);
var constraintErrors = GtsRefValidator.ValidateConstraints(entity.Content, entity.GtsId.Id, registered, refValidationMode);
if (constraintErrors.Count > 0)
return new AddResult(false, entity.GtsId.Id, entity.SchemaId, true, "x-gts-ref validation failed: " + string.Join("; ", constraintErrors));
var schemaVr = await registry.ValidateSchemaAsync(entity.GtsId, entity.Content, cancellationToken, refValidationMode)
.ConfigureAwait(false);
if (!schemaVr.Ok)
{
var msg = schemaVr.Errors is { Count: > 0 }
? string.Join("; ", schemaVr.Errors)
: (schemaVr.FailureReason ?? "Schema validation failed");
: (schemaVr.FailureReason?.ToWire() ?? "Schema validation failed");
return new AddResult(false, entity.GtsId.Id, entity.SchemaId, true, msg);
}
}

await registry.SaveAsync(entity).ConfigureAwait(false);
if (await registry.TrySaveAsync(entity).ConfigureAwait(false) == GtsSaveOutcome.Conflict)
return new AddResult(false, entity.GtsId.Id, entity.SchemaId, true, "Entity already exists with different content", true);

var schemaIdOut = entity.GtsId.Id;
return new AddResult(true, schemaIdOut, string.IsNullOrEmpty(entity.SchemaId) ? null : entity.SchemaId, true, null);
}

await registry.SaveAsync(entity).ConfigureAwait(false);

if (validate && !entity.IsSchema && entity.GtsId is not null)
if (validate && !entity.IsSchema)
{
var vr = await registry.ValidateInstanceAsync(entity.GtsId.Id, cancellationToken).ConfigureAwait(false);
if (!vr.Ok)
return new AddResult(false, entity.GtsId.Id, entity.SchemaId, false, vr.FailureReason ?? "Validation failed");
if (string.IsNullOrEmpty(entity.SchemaId) || !GtsId.TryParse(entity.SchemaId, out var schemaId) || schemaId is null || !schemaId.IsType)
return new AddResult(false, entity.GtsId?.Id ?? extract.Id ?? "", entity.SchemaId, false, "Unable to determine instance type");
var validation = await registry.ValidateJsonAsync(entity.Content, schemaId, entity.GtsId?.Id ?? extract.Id, cancellationToken, refValidationMode)
.ConfigureAwait(false);
if (!validation.Ok)
{
var error = validation.SchemaErrors is { Count: > 0 }
? string.Join("; ", validation.SchemaErrors)
: validation.FailureReason?.ToWire() ?? "Validation failed";
return new AddResult(false, entity.GtsId?.Id ?? extract.Id ?? "", entity.SchemaId, false, error);
}
}

var idOut = entity.GtsId?.Id ?? extract.Id ?? "";
if (await registry.TrySaveAsync(entity).ConfigureAwait(false) == GtsSaveOutcome.Conflict)
return new AddResult(false, idOut, entity.SchemaId, entity.IsSchema, "Entity already exists with different content", true);
return new AddResult(true, idOut, string.IsNullOrEmpty(entity.SchemaId) ? null : entity.SchemaId, entity.IsSchema, null);
}

private static bool TryGetSupportedDialect(JsonObject body, out string? error)
/// <summary>
/// Runs the structural checks for a batch Type Schema entry (canonical $schema/$id, dialect, GTS
/// keyword placement, $ref format and x-gts-ref patterns) and builds the entity WITHOUT touching the
/// store. Returns the entity on success or an error message. Mirrors the schema branch of
/// <see cref="TryAddAsync"/> so a batch entry is checked exactly like a single POST /entities schema.
/// </summary>
public static (GtsJsonEntity? Entity, string? Error) PrepareSchema(JsonObject body, GtsExtractOptions? extractOptions = null)
{
error = null;
if (!body.TryGetPropertyValue("$schema", out var node) || node is not JsonValue value ||
!value.TryGetValue<string>(out var dialect) || string.IsNullOrEmpty(dialect))
var opt = extractOptions ?? GtsExtractOptions.Default;
var entity = GtsJsonEntity.ExtractEntity(body, opt);
if (!entity.IsSchema || entity.GtsId is null)
return (null, "Unable to detect GTS ID in schema");

var rawId = body.TryGetPropertyValue("$id", out var idn) && idn is JsonValue idValue && idValue.TryGetValue<string>(out var id)
? id
: null;
if (!string.IsNullOrEmpty(rawId) && rawId.StartsWith(GtsConstants.IdPrefix, StringComparison.Ordinal) &&
!rawId.StartsWith(GtsConstants.UriPrefix, StringComparison.Ordinal))
return (null, "Schema $id must use gts:// URI format, not plain gts. prefix");
if (!GtsTypeSchema.TryGetSupportedDialect(body, out _, out var dialectError))
return (null, dialectError);
var keywordErrors = GtsSchemaKeywordValidator.Validate(body);
if (keywordErrors.Count > 0)
return (null, string.Join("; ", keywordErrors));
var refErrors = GtsRefValidator.ValidatePatterns(body, entity.GtsId.Id);
if (refErrors.Count > 0)
return (null, "x-gts-ref validation failed: " + string.Join("; ", refErrors));
try
{
error = "Schema must contain a supported $schema dialect";
return false;
GtsSchemaRefFormatValidator.ValidateRefs(body);
}
catch (Exception ex)
{
return (null, ex.Message);
}
return (entity, null);
}

if (dialect is "http://json-schema.org/draft-07/schema#" or
"https://json-schema.org/draft-07/schema#" or
"https://json-schema.org/draft/2019-09/schema" or
"https://json-schema.org/draft/2019-09/schema#" or
"https://json-schema.org/draft/2020-12/schema" or
"https://json-schema.org/draft/2020-12/schema#")
return true;

error = $"Unsupported JSON Schema dialect: {dialect}";
return false;
/// <summary>
/// Runs the semantic validation of a staged schema (x-gts-ref existence, derivation/traits) against the
/// current staged+committed set, returning an error message on failure or null on success. Does not
/// mutate the store: the caller commits the staged entry on success or discards it on failure.
/// </summary>
public static async Task<string?> ValidateStagedSchemaAsync(
GtsRegistry registry,
GtsJsonEntity entity,
GtsRefValidationMode refValidationMode,
CancellationToken cancellationToken = default,
string? stagingSessionId = null)
{
if (entity.GtsId is null)
return "Unable to detect GTS ID in schema";
var registered = await registry.SnapshotForReadAsync(stagingSessionId).ConfigureAwait(false);
var constraintErrors = GtsRefValidator.ValidateConstraints(entity.Content, entity.GtsId.Id, registered, refValidationMode);
if (constraintErrors.Count > 0)
return "x-gts-ref validation failed: " + string.Join("; ", constraintErrors);
var schemaVr = await registry.ValidateSchemaAsync(entity.GtsId, entity.Content, cancellationToken, refValidationMode, stagingSessionId)
.ConfigureAwait(false);
if (!schemaVr.Ok)
return schemaVr.Errors is { Count: > 0 }
? string.Join("; ", schemaVr.Errors)
: (schemaVr.FailureReason?.ToWire() ?? "Schema validation failed");
return null;
}
}
Loading