Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
21 changes: 17 additions & 4 deletions src/did/+did2/+convert/v1_to_v2.m
Original file line number Diff line number Diff line change
Expand Up @@ -178,10 +178,14 @@
% Make sure every class in the V_delta schema chain for the body's
% concrete class has a property block in the document, manufacturing
% empty `struct()` blocks for any chain entry that the v1 source did
% not provide. V_delta's validator rejects documents whose chain
% blocks are missing, so this padding lets the per-class migrators
% stay focused on real field moves rather than placeholder
% bookkeeping.
% not provide. Also rebuilds document_class.superclasses from the
% V_delta schema chain so the snapshot matches the spec (same set,
% same order, class-name-by-class-name) even when V_delta has
% reordered or extended the chain relative to v1. V_delta's
% validator rejects documents whose chain blocks are missing or
% whose superclasses snapshot drifts from the schema, so this
% padding lets the per-class migrators stay focused on real field
% moves rather than placeholder bookkeeping.
%
% Silent no-op if the schema cache cannot resolve the class chain
% (e.g., the class is unknown to the cache, or the cache itself is
Expand All @@ -206,6 +210,7 @@
end
try
chain = cache.classChain(className);
ancestors = cache.superclasses(className);
catch
return;
end
Expand All @@ -215,6 +220,14 @@
body.(cls) = struct();
end
end
sc = struct('class_name', {}, 'class_version', {});
for k = 1:numel(ancestors)
ancDC = cache.getClass(ancestors{k}).document_class;
sc(end+1) = struct( ...
'class_name', char(ancDC.class_name), ...
'class_version', char(ancDC.class_version)); %#ok<AGROW>
end
body.document_class.superclasses = sc;
end

function body = applySuperclassMigrators(body, concreteClassName)
Expand Down
53 changes: 53 additions & 0 deletions src/did/+did2/+schema/cache.m
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,34 @@ function validateDocument(obj, docOrStruct)
['Class "%s" is declared abstract; documents must ' ...
'instantiate a concrete subclass.'], className);
end
% V_gamma_SPEC §"Validation checklist": the
% document_class.superclasses snapshot must equal the chain
% derived from the schema files (same set, same order,
% class-name-by-class-name). buildBlankDocument and the
% v1->v2 migrator both honour this by construction; this
% check catches hand-built docs and serialisers that emit
% only the immediate parent — a truncated chain breaks
% isa-style queries downstream (e.g., classLineage on the
% cloud), so flag it at the boundary.
if ~isfield(dc, 'superclasses')
error('did2:validation:missingSuperclasses', ...
['document_class.superclasses is required (empty ' ...
'[] for base). Class "%s" expects %d entries.'], ...
className, numel(obj.superclasses(className)));
end
expectedAncestors = obj.superclasses(className);
declaredAncestors = obj.superclassClassNames(dc.superclasses);
if numel(declaredAncestors) ~= numel(expectedAncestors) ...
|| ~all(cellfun(@strcmp, declaredAncestors, expectedAncestors))
error('did2:validation:superclassesChainMismatch', ...
['document_class.superclasses for "%s" is {%s} but ' ...
'the schema chain is {%s}. V_delta requires the ' ...
'snapshot to match the schema-derived chain ' ...
'class-name-by-class-name.'], ...
className, ...
strjoin(declaredAncestors, ', '), ...
strjoin(expectedAncestors, ', '));
end
chain = obj.classChain(className);
for k = 1:numel(chain)
blockClass = chain{k};
Expand Down Expand Up @@ -583,6 +611,31 @@ function loadRegistry(obj)
elem = cells{idx};
end

function names = superclassClassNames(obj, raw)
% Extract the class_name from each entry of a
% document_class.superclasses array. Accepts the empty
% array `[]` (jsondecode of `[]`), an empty struct array,
% a single struct, or an N-element struct array. Raises
% did2:validation:badSuperclassEntry on malformed entries.
if isempty(raw)
names = {};
return;
end
cells = obj.toCellArray(raw);
names = cell(1, numel(cells));
for k = 1:numel(cells)
entry = cells{k};
if ~isstruct(entry) || ~isfield(entry, 'class_name') ...
|| isempty(entry.class_name)
error('did2:validation:badSuperclassEntry', ...
['document_class.superclasses(%d) is missing ' ...
'class_name; every snapshot entry must carry ' ...
'at least class_name.'], k);
end
names{k} = char(entry.class_name);
end
end

function block = buildBlockForClass(obj, className)
% buildBlockForClass - one property block populated with
% `blank_value` for every field the class declares
Expand Down
76 changes: 76 additions & 0 deletions tests/+did2/+unittest/testSchemaCache.m
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,82 @@ function testValidateMissingClassBlockThrows(testCase)
'did2:validation:missingClassBlock');
end

function testValidateTruncatedSuperclassesChainThrows(testCase)
% V_gamma_SPEC "Validation checklist": the document_class.superclasses
% snapshot must match the schema-derived chain. demoB's chain is
% {demoA, base}; truncating to {demoA} must fail loudly so consumers
% (e.g., cloud classLineage) get a complete is-a transitive closure.
cache = testCase.TestData.cache;
doc = cache.buildBlankDocument('demoB');
doc.base.session_id = did.ido.unique_id();
doc.demoA.value = 'x';
doc.demoB.value_b = 'y';
% Sanity: blank doc validates first.
cache.validateDocument(doc);
truncated = doc;
truncated.document_class.superclasses = ...
doc.document_class.superclasses(1);
verifyError(testCase, ...
@() cache.validateDocument(truncated), ...
'did2:validation:superclassesChainMismatch');
end

function testValidateReorderedSuperclassesChainThrows(testCase)
% Spec requires same order, not just same set.
cache = testCase.TestData.cache;
doc = cache.buildBlankDocument('demoB');
doc.base.session_id = did.ido.unique_id();
doc.demoA.value = 'x';
doc.demoB.value_b = 'y';
reordered = doc;
reordered.document_class.superclasses = ...
doc.document_class.superclasses([2 1]);
verifyError(testCase, ...
@() cache.validateDocument(reordered), ...
'did2:validation:superclassesChainMismatch');
end

function testValidateMissingSuperclassesFieldThrows(testCase)
cache = testCase.TestData.cache;
doc = cache.buildBlankDocument('demoA');
doc.base.session_id = did.ido.unique_id();
doc.demoA.value = 'x';
doc.document_class = rmfield(doc.document_class, 'superclasses');
verifyError(testCase, ...
@() cache.validateDocument(doc), ...
'did2:validation:missingSuperclasses');
end

function testValidateBadSuperclassEntryThrows(testCase)
cache = testCase.TestData.cache;
doc = cache.buildBlankDocument('demoA');
doc.base.session_id = did.ido.unique_id();
doc.demoA.value = 'x';
doc.document_class.superclasses(1).class_name = '';
verifyError(testCase, ...
@() cache.validateDocument(doc), ...
'did2:validation:badSuperclassEntry');
end

function testValidateBaseAcceptsEmptySuperclasses(testCase)
% Spec: superclasses must be `[]` for base. buildBlankDocument cannot
% mint a `base` doc (`base` lacks the concrete declarations it needs),
% so build the smallest valid base doc by hand and confirm it passes.
cache = testCase.TestData.cache;
doc = struct();
doc.document_class = struct( ...
'class_name', 'base', ...
'class_version', '1.0.0', ...
'superclasses', []);
doc.depends_on = struct('name', {}, 'value', {});
doc.base = struct( ...
'id', did.ido.unique_id(), ...
'session_id', did.ido.unique_id(), ...
'name', 'rig_1', ...
'datestamp', '2026-01-01T00:00:00.000Z');
cache.validateDocument(doc);
end

% ---- end-to-end through did2.document ----

function testDocumentBlankConvenience(testCase)
Expand Down
Loading