1#include "arachne/contracts.hpp"
3#include <nlohmann/json.hpp>
10#include <initializer_list>
31 {
"research_candidate_graph_plan_v1",
33 {
"product_graph_snapshot_v1",
35 {
"research_candidate_graph_snapshot_v1",
43 std::string message) {
44 result.diagnostics.push_back(
45 { std::move(path), std::move(code), std::move(message) }
50 child_path(
const std::string_view parent,
const std::string_view key) {
52 return "/" + std::string(key);
54 return std::string(parent) +
"/" + std::string(key);
58 const json& object,
const std::string_view key,
61 const auto it = object.find(key);
62 if (it == object.end()) {
63 add(result, child_path(path, key),
"required",
64 "required field is missing");
71 const json& object,
const std::string_view path,
72 const std::initializer_list<std::string_view> allowed,
75 if (!object.is_object()) {
78 for (
const auto& [key, unused] : object.items()) {
80 if (std::ranges::find(allowed, std::string_view(key))
82 add(result, child_path(path, key),
"unknown_field",
83 "field is not defined by this contract version");
89 const json& object,
const std::string_view key,
92 const json* value = field(object, key, path, result);
93 if (value !=
nullptr && !value->is_object()) {
94 add(result, child_path(path, key),
"type",
95 "expected a JSON object");
102 const json& object,
const std::string_view key,
105 const json* value = field(object, key, path, result);
106 if (value !=
nullptr && !value->is_array()) {
107 add(result, child_path(path, key),
"type",
"expected a JSON array");
114 const json& object,
const std::string_view key,
117 const auto it = object.find(key);
118 if (it == object.end()) {
121 if (!it->is_object()) {
122 add(result, child_path(path, key),
"type",
123 "expected a JSON object");
130 const json& object,
const std::string_view key,
133 const auto it = object.find(key);
134 if (it == object.end()) {
137 if (!it->is_array()) {
138 add(result, child_path(path, key),
"type",
139 "expected a JSON array");
146 const json& object,
const std::string_view key,
149 const json* value = field(object, key, path, result);
150 if (value ==
nullptr) {
153 if (!value->is_string()) {
154 add(result, child_path(path, key),
"type",
155 "expected a JSON string");
158 if (value->get_ref<
const std::string&>().empty()) {
159 add(result, child_path(path, key),
"min_length",
160 "string must not be empty");
167 const json& object,
const std::string_view key,
170 const auto it = object.find(key);
171 if (it == object.end()) {
174 if (!it->is_string()) {
175 add(result, child_path(path, key),
"type",
176 "expected a JSON string");
179 if (it->get_ref<
const std::string&>().empty()) {
180 add(result, child_path(path, key),
"min_length",
181 "string must not be empty");
188 const json* value,
const std::initializer_list<std::string_view> choices
190 if (value ==
nullptr || !value->is_string()) {
193 const std::string& text = value->get_ref<
const std::string&>();
194 return std::ranges::find(choices, std::string_view(text))
199 const json& object,
const std::string_view key,
200 const std::string_view path,
201 const std::initializer_list<std::string_view> choices,
204 const json* value = require_string(object, key, path, result);
205 if (value !=
nullptr && !string_is_one_of(value, choices)) {
206 add(result, child_path(path, key),
"enum",
207 "value is not one of the allowed strings");
211 bool matches(
const json* value,
const std::regex& expression) {
212 if (value ==
nullptr || !value->is_string()) {
215 return std::regex_match(
216 value->get_ref<
const std::string&>(), expression
221 const json& object,
const std::string_view key,
224 static const std::regex expression(
225 R"(^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$)"
227 static const std::regex canonical_entity(
228 R"(^(agent|work|concept|manifestation)-[0-9]{6,}$)"
230 const json* value = require_string(object, key, path, result);
231 if (value !=
nullptr && !matches(value, expression)) {
232 add(result, child_path(path, key),
"pattern",
233 "expected a stable identifier of at most 128 characters");
235 value !=
nullptr && key ==
"local_id"
236 && matches(value, canonical_entity)
238 add(result, child_path(path, key),
"reserved_identifier",
239 "local IDs must not use a canonical entity ID pattern");
244 const json& object,
const std::string_view key,
247 static const std::regex expression(R"(^[0-9a-f]{64}$)");
248 const json* value = require_string(object, key, path, result);
249 if (value !=
nullptr && !matches(value, expression)) {
250 add(result, child_path(path, key),
"sha256",
251 "expected a lowercase 64-character SHA-256 digest");
256 const json& object,
const std::string_view key,
259 static const std::regex expression(
260 R"(^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?(Z|[+-][0-9]{2}:[0-9]{2})$)"
262 const json* value = require_string(object, key, path, result);
263 if (value !=
nullptr && !matches(value, expression)) {
264 add(result, child_path(path, key),
"date_time",
265 "expected an RFC 3339 date-time with an explicit offset");
270 const json& object,
const std::string_view key,
273 const json* value = field(object, key, path, result);
274 if (value ==
nullptr) {
277 if (!value->is_number_integer() && !value->is_number_unsigned()) {
278 add(result, child_path(path, key),
"type",
279 "expected a non-negative integer");
282 if (value->is_number_integer() && value->get<
long long>() < 0) {
283 add(result, child_path(path, key),
"minimum",
284 "integer must be non-negative");
289 const json& object,
const std::string_view key,
292 const json* value = field(object, key, path, result);
293 if (value !=
nullptr && !value->is_boolean()) {
294 add(result, child_path(path, key),
"type",
295 "expected a JSON boolean");
300 const json& object,
const std::string_view key,
301 const std::string_view path,
const std::int64_t minimum,
304 const auto it = object.find(key);
305 if (it == object.end()) {
308 if (!it->is_number_integer() && !it->is_number_unsigned()) {
309 add(result, child_path(path, key),
"type",
"expected an integer");
312 const long double value = it->is_number_unsigned()
313 ?
static_cast<
long double>(it->get<std::uint64_t>())
314 :
static_cast<
long double>(it->get<std::int64_t>());
315 if (value <
static_cast<
long double>(minimum)
316 || value >
static_cast<
long double>(maximum)) {
317 add(result, child_path(path, key),
"range",
318 "integer is outside the permitted range");
323 const json& object,
const std::string_view key,
324 const std::string_view path,
const std::int64_t minimum,
327 if (!object.contains(key)) {
328 field(object, key, path, result);
331 optional_integer_range(
332 object, key, path, minimum, maximum, result
337 const json& object,
const std::string_view key,
338 const std::string_view path,
const double minimum,
341 const auto it = object.find(key);
342 if (it == object.end()) {
345 if (!it->is_number()) {
346 add(result, child_path(path, key),
"type",
"expected a number");
349 const double value = it->get<
double>();
350 if (!std::isfinite(value) || value < minimum || value > maximum) {
351 add(result, child_path(path, key),
"range",
352 "number is outside the permitted range");
357 const json& object,
const std::string_view key,
358 const std::string_view path,
const double minimum,
361 if (!object.contains(key)) {
362 field(object, key, path, result);
365 optional_number_range(object, key, path, minimum, maximum, result);
369 const json& object,
const std::string_view key,
370 const std::string_view path,
371 const std::initializer_list<std::string_view> choices,
374 const json* value = optional_string(object, key, path, result);
375 if (value !=
nullptr && !string_is_one_of(value, choices)) {
376 add(result, child_path(path, key),
"enum",
377 "value is not one of the allowed strings");
382 const json& object,
const std::string_view key,
383 const std::string_view path,
const std::regex& expression,
386 const json* value = require_string(object, key, path, result);
387 if (value !=
nullptr && !matches(value, expression)) {
388 add(result, child_path(path, key),
"pattern",
389 "string does not match the required canonical form");
394 const json& object,
const std::string_view key,
395 const std::string_view path,
const std::regex& expression,
398 const json* value = optional_string(object, key, path, result);
399 if (value !=
nullptr && !matches(value, expression)) {
400 add(result, child_path(path, key),
"pattern",
401 "string does not match the required canonical form");
406 const json& object,
const std::string_view key,
407 const std::string_view path,
const std::uint64_t minimum,
410 const auto it = object.find(key);
411 if (it == object.end()) {
414 bool in_range =
false;
415 if (it->is_number_unsigned()) {
416 const std::uint64_t value = it->get<std::uint64_t>();
417 in_range = value >= minimum && value <= maximum;
418 }
else if (it->is_number_integer()) {
419 const std::int64_t value = it->get<std::int64_t>();
420 in_range = value >= 0
421 &&
static_cast<std::uint64_t>(value) >= minimum
422 &&
static_cast<std::uint64_t>(value) <= maximum;
424 add(result, child_path(path, key),
"type",
"expected an integer");
428 add(result, child_path(path, key),
"range",
429 "integer is outside the permitted safety range");
434 const json& object,
const std::string_view path,
437 static const std::regex expression(
438 R"(^[a-z][a-z0-9]*(\.[a-z0-9][a-z0-9_-]*)+$)"
440 const json* extensions
441 = optional_object(object,
"extensions", path, result);
442 if (extensions ==
nullptr) {
445 for (
const auto& [key, unused] : extensions->items()) {
447 if (!std::regex_match(key, expression)) {
449 result, child_path(child_path(path,
"extensions"), key),
450 "extension_namespace",
451 "extension keys must be reverse-DNS-style namespaced names"
458 const json& artifact,
const std::string_view path,
461 if (!artifact.is_object()) {
462 add(result, std::string(path),
"type",
463 "expected an artifact object");
466 reject_unknown_fields(
468 {
"storage_ref",
"sha256",
"byte_length",
"media_type" }, result
470 require_string(artifact,
"storage_ref", path, result);
471 require_sha256(artifact,
"sha256", path, result);
472 require_nonnegative_integer(artifact,
"byte_length", path, result);
473 optional_string(artifact,
"media_type", path, result);
477 const json& object,
const std::string_view key,
480 const json* artifact = require_object(object, key, path, result);
481 if (artifact !=
nullptr) {
482 validate_artifact(*artifact, child_path(path, key), result);
487 const json& object,
const std::string_view key,
490 const json* artifacts = require_array(object, key, path, result);
491 if (artifacts ==
nullptr) {
494 if (artifacts->empty()) {
495 add(result, child_path(path, key),
"min_items",
496 "at least one artifact is required");
498 for (std::size_t index = 0; index < artifacts->size(); ++index) {
499 const std::string item_path
500 = child_path(path, key) +
"/" + std::to_string(index);
501 const json& entry = (*artifacts)[index];
502 if (!entry.is_object()) {
503 add(result, item_path,
"type",
"expected an export object");
506 reject_unknown_fields(
507 entry, item_path, {
"kind",
"artifact" }, result
509 require_string(entry,
"kind", item_path, result);
510 validate_artifact_field(entry,
"artifact", item_path, result);
515 const json& object,
const std::string_view key,
516 const std::string_view path,
const bool require_nonempty,
519 const json* values = require_array(object, key, path, result);
520 if (values ==
nullptr) {
523 if (require_nonempty && values->empty()) {
524 add(result, child_path(path, key),
"min_items",
525 "array must contain at least one item");
527 for (std::size_t index = 0; index < values->size(); ++index) {
528 if (!(*values)[index].is_string() || (*values)[index].empty()) {
529 add(result, child_path(path, key) +
"/" + std::to_string(index),
530 "type",
"expected a non-empty string");
536 const std::size_t separator = name.rfind(
"_v");
537 if (separator == std::string_view::npos
538 || separator + 2 >= name.size()) {
541 unsigned int major = 0;
542 const char* begin = name.data() + separator + 2;
543 const char* end = name.data() + name.size();
544 const auto parsed = std::from_chars(begin, end, major);
545 if (parsed.ec != std::errc {} || parsed.ptr != end) {
552 const std::size_t separator = name.rfind(
"_v");
553 if (separator == std::string_view::npos) {
556 const std::string_view base = name.substr(0, separator);
557 return std::ranges::any_of(contract_names, [base](
const auto& entry) {
558 const std::string_view supported = entry.first;
559 const std::size_t supported_separator = supported.rfind(
"_v");
560 return supported.substr(0, supported_separator) == base;
566 if (!document.is_object()) {
567 add(result,
"",
"type",
"contract document must be a JSON object");
570 const auto format = document.find(
"format");
571 if (format != document.end()) {
572 if (!format->is_string()) {
573 add(result,
"/format",
"type",
"expected a JSON string");
576 const std::string& name = format->get_ref<
const std::string&>();
577 if (name ==
"arachne_batch_v2") {
580 if (name.starts_with(
"arachne_batch_v")) {
581 add(result,
"/format",
"unsupported_major",
582 "unsupported Arachne batch major version");
584 add(result,
"/format",
"unknown_contract",
585 "unknown or malformed batch format");
589 const auto it = document.find(
"contract");
590 if (it == document.end()) {
591 add(result,
"/contract",
"required",
"required field is missing");
594 if (!it->is_string()) {
595 add(result,
"/contract",
"type",
"expected a JSON string");
598 const std::string& name = it->get_ref<
const std::string&>();
599 if (
const auto parsed = parse_contract_name(name); parsed.has_value()) {
602 if (known_contract_base(name) && declared_major(name).has_value()) {
603 add(result,
"/contract",
"unsupported_major",
604 "unsupported contract major version "
605 + std::to_string(*declared_major(name)));
607 add(result,
"/contract",
"unknown_contract",
608 "unknown or malformed contract name");
618 const auto discovered = inspect_contract(document, result);
619 if (discovered.has_value() && *discovered != expected) {
620 add(result, document.contains(
"format") ?
"/format" :
"/contract",
622 "expected arachne_batch_v2 but received "
623 + std::string(to_string(*discovered)));
628 const auto discovered = inspect_contract(document, result);
629 if (discovered.has_value() && *discovered != expected) {
630 add(result,
"/contract",
"contract_mismatch",
631 "expected " + std::string(to_string(expected))
633 + std::string(to_string(*discovered)));
636 const auto version = document.find(
"format_version");
637 if (version == document.end()) {
638 add(result,
"/format_version",
"required",
639 "required field is missing");
641 !version->is_number_integer() && !version->is_number_unsigned()
643 add(result,
"/format_version",
"type",
644 "format_version must be an integer");
645 }
else if (*version != 1) {
646 add(result,
"/format_version",
"unsupported_version",
647 "only format version 1 is supported");
652 const json& value,
const std::string_view path,
655 static const std::regex expression(
656 R"(^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$)"
658 static const std::regex canonical_entity(
659 R"(^(agent|work|concept|manifestation)-[0-9]{6,}$)"
661 if (!value.is_string()) {
662 add(result, std::string(path),
"type",
663 "expected a stable string reference");
664 }
else if (!std::regex_match(
665 value.get_ref<
const std::string&>(), expression
667 add(result, std::string(path),
"pattern",
668 "expected a stable identifier of at most 128 characters");
670 path.ends_with(
"/local_id")
672 value.get_ref<
const std::string&>(), canonical_entity
675 add(result, std::string(path),
"reserved_identifier",
676 "local IDs must not use a canonical entity ID pattern");
681 const json& value,
const std::string_view path,
684 if (value.is_string()) {
685 validate_stable_reference(value, path, result);
686 static const std::regex canonical_entity(
687 R"(^(agent|work|concept|manifestation)-[0-9]{6,}$)"
689 if (std::regex_match(
690 value.get_ref<
const std::string&>(), canonical_entity
692 add(result, std::string(path),
"reserved_identifier",
693 "local references must not use a canonical entity ID pattern");
697 const bool positive_integer
698 = (value.is_number_unsigned() && value.get<std::uint64_t>() >= 1)
699 || (value.is_number_integer() && value.get<std::int64_t>() >= 1);
700 if (!positive_integer) {
701 add(result, std::string(path),
"type",
702 "expected a positive database ID or stable local reference");
707 const json& array,
const std::string_view path,
710 std::set<std::string> seen;
711 for (std::size_t index = 0; index < array.size(); ++index) {
712 const std::string key = array[index].dump();
713 if (!seen.insert(key).second) {
714 add(result, std::string(path) +
"/" + std::to_string(index),
715 "unique_items",
"array items must be unique");
720 template<
typename Validator>
722 const json& object,
const std::string_view key,
724 Validator&& validator
726 const json* values = optional_array(object, key, path, result);
727 if (values ==
nullptr) {
730 const std::string array_path = child_path(path, key);
731 for (std::size_t index = 0; index < values->size(); ++index) {
732 const std::string item_path
733 = array_path +
"/" + std::to_string(index);
734 const json& item = (*values)[index];
735 if (!item.is_object()) {
736 add(result, item_path,
"type",
"expected a JSON object");
739 validator(item, item_path, result);
744 const json& object,
const std::string_view path,
745 const std::initializer_list<std::string_view> keys,
748 for (
const std::string_view key : keys) {
749 optional_string(object, key, path, result);
754 const json& object,
const std::string_view path,
755 const std::initializer_list<std::string_view> keys,
758 for (
const std::string_view key : keys) {
759 optional_integer_range(object, key, path, -9999, 9999, result);
764 const json& object,
const std::string_view key,
765 const std::string_view path,
const std::string_view family,
768 const std::regex expression(
769 "^" + std::string(family) + R"(-[0-9]{6,}$)"
771 require_pattern(object, key, path, expression, result);
775 const json& object,
const std::string_view key,
778 const json* values = optional_array(object, key, path, result);
779 if (values ==
nullptr) {
782 validate_unique_items(*values, child_path(path, key), result);
783 for (std::size_t index = 0; index < values->size(); ++index) {
784 const json& value = (*values)[index];
785 const bool positive_integer = (value.is_number_unsigned()
786 && value.get<std::uint64_t>() >= 1)
787 || (value.is_number_integer()
788 && value.get<std::int64_t>() >= 1);
789 if (!positive_integer) {
791 child_path(path, key) +
"/" + std::to_string(index),
792 "minimum",
"expected a positive integer database ID");
798 const json& object,
const std::string_view key,
801 const json* values = require_array(object, key, path, result);
802 if (values ==
nullptr) {
805 const std::string array_path = child_path(path, key);
806 if (values->empty()) {
807 add(result, array_path,
"min_items",
808 "at least one source-backed evidence reference is required");
810 validate_unique_items(*values, array_path, result);
811 for (std::size_t index = 0; index < values->size(); ++index) {
812 validate_database_or_local_reference(
814 array_path +
"/" + std::to_string(index), result
820 const json& create,
const std::string_view path,
823 reject_unknown_fields(
825 {
"agents",
"works",
"concepts",
"manifestations",
"names",
826 "external_ids",
"sources",
"evidence",
"credits",
827 "measurements",
"financial_facts",
"work_concepts",
828 "concept_relations",
"parent_guide_assertions" },
832 validate_optional_object_array(
833 create,
"agents", path, result,
834 [](
const json& item,
const std::string& item_path,
836 reject_unknown_fields(
838 {
"local_id",
"agent_type",
"birth_year",
"death_year" },
841 require_stable_id(item,
"local_id", item_path, item_result);
843 item,
"agent_type", item_path,
844 {
"person",
"organization",
"group" }, item_result
846 validate_year_fields(
847 item, item_path, {
"birth_year",
"death_year" },
853 validate_optional_object_array(
854 create,
"works", path, result,
855 [](
const json& item,
const std::string& item_path,
857 reject_unknown_fields(
859 {
"local_id",
"medium",
"year_start",
"year_end",
860 "date_precision",
"date_start_text",
"date_end_text",
861 "date_qualifier",
"language_code",
"country_code",
862 "production_info_json" },
865 require_stable_id(item,
"local_id", item_path, item_result);
867 item,
"medium", item_path,
868 {
"film",
"short_film",
"television",
"novel",
"novella",
869 "short_story",
"poetry",
"play",
"essay",
"album",
870 "single",
"composition",
"painting",
"print",
871 "engraving",
"drawing",
"sculpture",
"installation",
872 "photography",
"mixed_media" },
875 validate_year_fields(
876 item, item_path, {
"year_start",
"year_end" }, item_result
879 item,
"date_precision", item_path,
880 {
"year",
"decade",
"approximate",
"range",
"exact" },
883 validate_nonempty_optional_strings(
885 {
"date_start_text",
"date_end_text",
"date_qualifier",
886 "language_code",
"country_code",
887 "production_info_json" },
893 validate_optional_object_array(
894 create,
"concepts", path, result,
895 [](
const json& item,
const std::string& item_path,
897 static const std::regex slug(
898 R"(^[a-z0-9]+(?:-[a-z0-9]+)*$)"
900 reject_unknown_fields(
901 item, item_path, {
"local_id",
"concept_type",
"slug" },
904 require_stable_id(item,
"local_id", item_path, item_result);
906 item,
"concept_type", item_path,
907 {
"genre",
"style",
"theme",
"keyword",
"motif",
"trope",
908 "phobia",
"taboo",
"technique",
"movement",
"setting",
909 "mood",
"content_warning" },
912 require_pattern(item,
"slug", item_path, slug, item_result);
916 validate_optional_object_array(
917 create,
"manifestations", path, result,
918 [](
const json& item,
const std::string& item_path,
920 reject_unknown_fields(
922 {
"local_id",
"work_id",
"manifestation_type",
923 "release_year",
"region_code",
"language_code",
"label" },
926 require_stable_id(item,
"local_id", item_path, item_result);
928 = field(item,
"work_id", item_path, item_result);
929 if (work !=
nullptr) {
930 validate_stable_reference(
931 *work, child_path(item_path,
"work_id"), item_result
935 item,
"manifestation_type", item_path,
936 {
"edition",
"translation",
"release",
"pressing",
"cut",
937 "restoration",
"reissue" },
940 optional_integer_range(
941 item,
"release_year", item_path, -9999, 9999, item_result
943 validate_nonempty_optional_strings(
944 item, item_path, {
"region_code",
"language_code" },
947 require_string(item,
"label", item_path, item_result);
951 validate_optional_object_array(
952 create,
"names", path, result,
953 [](
const json& item,
const std::string& item_path,
955 reject_unknown_fields(
957 {
"entity_id",
"name_type",
"language_code",
958 "script_code",
"value",
"is_preferred" },
962 = field(item,
"entity_id", item_path, item_result);
963 if (entity !=
nullptr) {
964 validate_stable_reference(
965 *entity, child_path(item_path,
"entity_id"), item_result
969 item,
"name_type", item_path,
970 {
"original",
"english",
"transliteration",
"translation",
971 "alias",
"credited" },
974 validate_nonempty_optional_strings(
975 item, item_path, {
"language_code",
"script_code" },
978 require_string(item,
"value", item_path, item_result);
980 item,
"is_preferred", item_path, item_result
985 validate_optional_object_array(
986 create,
"external_ids", path, result,
987 [](
const json& item,
const std::string& item_path,
989 reject_unknown_fields(
991 {
"entity_id",
"scheme",
"value",
"canonical_url" },
995 = field(item,
"entity_id", item_path, item_result);
996 if (entity !=
nullptr) {
997 validate_stable_reference(
998 *entity, child_path(item_path,
"entity_id"), item_result
1001 require_string(item,
"scheme", item_path, item_result);
1002 require_string(item,
"value", item_path, item_result);
1004 item,
"canonical_url", item_path, item_result
1009 validate_optional_object_array(
1010 create,
"sources", path, result,
1011 [](
const json& item,
const std::string& item_path,
1013 reject_unknown_fields(
1015 {
"local_id",
"source_type",
"title",
1016 "bibliography_text",
"author_text",
"publisher",
1017 "publication_date",
"url",
"doi",
"isbn",
1021 require_stable_id(item,
"local_id", item_path, item_result);
1023 item,
"source_type", item_path,
1024 {
"book",
"article",
"catalogue",
"web_page",
"interview",
1025 "database",
"video",
"audio",
"PDF" },
1028 validate_nonempty_optional_strings(
1030 {
"title",
"bibliography_text",
"author_text",
"publisher",
1031 "publication_date",
"url",
"doi",
"isbn",
1035 if (!item.contains(
"url") && !item.contains(
"doi")
1036 && !item.contains(
"isbn")
1037 && !item.contains(
"bibliography_text")) {
1038 add(item_result, item_path,
"any_of",
1039 "source requires url, doi, isbn, or bibliography_text");
1044 validate_optional_object_array(
1045 create,
"evidence", path, result,
1046 [](
const json& item,
const std::string& item_path,
1048 reject_unknown_fields(
1050 {
"local_id",
"source_id",
"exact_quote",
1051 "quote_language",
"quote_translation",
"locator_json",
1055 require_stable_id(item,
"local_id", item_path, item_result);
1057 = field(item,
"source_id", item_path, item_result);
1058 if (source !=
nullptr) {
1059 validate_database_or_local_reference(
1060 *source, child_path(item_path,
"source_id"), item_result
1063 require_string(item,
"exact_quote", item_path, item_result);
1064 validate_nonempty_optional_strings(
1066 {
"quote_language",
"quote_translation",
"locator_json" },
1070 item,
"stance", item_path,
1071 {
"supports",
"contradicts",
"contextualizes" },
1077 validate_optional_object_array(
1078 create,
"credits", path, result,
1079 [](
const json& item,
const std::string& item_path,
1081 reject_unknown_fields(
1083 {
"work_id",
"agent_id",
"role",
"credit_order",
1084 "importance",
"credited_as" },
1087 for (
const std::string_view key : {
"work_id",
"agent_id" }) {
1088 const json* reference
1089 = field(item, key, item_path, item_result);
1090 if (reference !=
nullptr) {
1091 validate_stable_reference(
1092 *reference, child_path(item_path, key), item_result
1097 item,
"role", item_path,
1098 {
"author",
"director",
"screenwriter",
"producer",
1099 "actor",
"composer",
"performer",
"artist",
"engraver",
1100 "sculptor",
"photographer",
"editor",
"cinematographer",
1101 "production_company",
"publisher",
"record_label",
1105 optional_integer_range(
1106 item,
"credit_order", item_path, 0,
1107 std::numeric_limits<std::int64_t>::max(), item_result
1110 item,
"importance", item_path,
1111 {
"primary",
"key",
"supporting" }, item_result
1113 optional_string(item,
"credited_as", item_path, item_result);
1117 validate_optional_object_array(
1118 create,
"measurements", path, result,
1119 [](
const json& item,
const std::string& item_path,
1121 reject_unknown_fields(
1123 {
"entity_id",
"measurement_type",
"value",
"unit",
1128 = field(item,
"entity_id", item_path, item_result);
1129 if (entity !=
nullptr) {
1130 validate_stable_reference(
1131 *entity, child_path(item_path,
"entity_id"), item_result
1135 item,
"measurement_type", item_path,
1136 {
"duration",
"height",
"width",
"depth",
"pages" },
1139 require_number_range(
1140 item,
"value", item_path, 0.0,
1141 std::numeric_limits<
double>::max(), item_result
1144 item,
"unit", item_path,
1145 {
"seconds",
"millimetres",
"pages" }, item_result
1147 optional_string(item,
"qualifier", item_path, item_result);
1151 validate_optional_object_array(
1152 create,
"financial_facts", path, result,
1153 [](
const json& item,
const std::string& item_path,
1155 static const std::regex currency(R"(^[A-Z]{3}$)");
1156 reject_unknown_fields(
1158 {
"work_id",
"fact_type",
"amount_min",
"amount_max",
1159 "currency_code",
"value_year",
"is_estimate",
1164 = field(item,
"work_id", item_path, item_result);
1165 if (work !=
nullptr) {
1166 validate_stable_reference(
1167 *work, child_path(item_path,
"work_id"), item_result
1171 = require_string(item,
"fact_type", item_path, item_result);
1172 if (fact !=
nullptr && *fact !=
"budget") {
1173 add(item_result, child_path(item_path,
"fact_type"),
1174 "const",
"fact_type must be budget");
1176 require_integer_range(
1177 item,
"amount_min", item_path, 0,
1178 std::numeric_limits<std::int64_t>::max(), item_result
1180 optional_integer_range(
1181 item,
"amount_max", item_path, 0,
1182 std::numeric_limits<std::int64_t>::max(), item_result
1185 item,
"currency_code", item_path, currency, item_result
1187 optional_integer_range(
1188 item,
"value_year", item_path, -9999, 9999, item_result
1191 item,
"is_estimate", item_path, item_result
1193 optional_number_range(
1194 item,
"confidence", item_path, 0.0, 1.0, item_result
1199 validate_optional_object_array(
1200 create,
"work_concepts", path, result,
1201 [](
const json& item,
const std::string& item_path,
1203 reject_unknown_fields(
1205 {
"local_id",
"work_id",
"concept_id",
"relation_type",
1206 "centrality",
"historical_role",
"confidence",
1210 require_stable_id(item,
"local_id", item_path, item_result);
1211 for (
const std::string_view key : {
"work_id",
"concept_id" }) {
1212 const json* reference
1213 = field(item, key, item_path, item_result);
1214 if (reference !=
nullptr) {
1215 validate_stable_reference(
1216 *reference, child_path(item_path, key), item_result
1221 item,
"relation_type", item_path,
1222 {
"exemplifies",
"contains",
"anticipates",
1223 "influenced_by",
"influences",
"revives",
"parodies",
1224 "deconstructs",
"associated_with" },
1227 require_integer_range(
1228 item,
"centrality", item_path, 1, 100, item_result
1231 item,
"historical_role", item_path,
1232 {
"formative",
"canonical",
"transitional",
"hybrid",
1233 "revival",
"late_derivative",
"peripheral",
"precursor" },
1236 optional_number_range(
1237 item,
"confidence", item_path, 0.0, 1.0, item_result
1239 validate_evidence_references(
1240 item,
"evidence", item_path, item_result
1245 validate_optional_object_array(
1246 create,
"concept_relations", path, result,
1247 [](
const json& item,
const std::string& item_path,
1249 reject_unknown_fields(
1251 {
"local_id",
"subject_concept_id",
"relation_type",
1252 "object_concept_id",
"strength",
"from_year",
"to_year",
1253 "region_code",
"confidence",
"evidence" },
1256 require_stable_id(item,
"local_id", item_path, item_result);
1257 for (
const std::string_view key :
1258 {
"subject_concept_id",
"object_concept_id" }) {
1259 const json* reference
1260 = field(item, key, item_path, item_result);
1261 if (reference !=
nullptr) {
1262 validate_stable_reference(
1263 *reference, child_path(item_path, key), item_result
1268 item,
"relation_type", item_path,
1269 {
"broader_than",
"narrower_than",
"derived_from",
1270 "precursor_of",
"hybrid_of",
"revival_of",
1271 "regional_variant_of",
"influenced_by",
"opposes",
1275 optional_integer_range(
1276 item,
"strength", item_path, 1, 100, item_result
1278 validate_year_fields(
1279 item, item_path, {
"from_year",
"to_year" }, item_result
1281 optional_string(item,
"region_code", item_path, item_result);
1282 optional_number_range(
1283 item,
"confidence", item_path, 0.0, 1.0, item_result
1285 validate_evidence_references(
1286 item,
"evidence", item_path, item_result
1291 validate_optional_object_array(
1292 create,
"parent_guide_assertions", path, result,
1293 [](
const json& item,
const std::string& item_path,
1295 reject_unknown_fields(
1297 {
"local_id",
"work_id",
"concept_id",
"category",
1298 "intensity",
"explicitness",
"frequency",
"centrality",
1299 "realism",
"spoiler_level",
"confidence",
"evidence" },
1302 require_stable_id(item,
"local_id", item_path, item_result);
1303 for (
const std::string_view key : {
"work_id",
"concept_id" }) {
1304 const json* reference
1305 = field(item, key, item_path, item_result);
1306 if (reference !=
nullptr) {
1307 validate_stable_reference(
1308 *reference, child_path(item_path, key), item_result
1313 item,
"category", item_path,
1314 {
"violence",
"sex_nudity",
"language",
"drugs",
1315 "frightening",
"self_harm",
"discrimination",
"abuse",
1319 for (
const std::string_view key :
1320 {
"intensity",
"explicitness",
"frequency",
"centrality",
1322 require_integer_range(
1323 item, key, item_path, 1, 5, item_result
1327 item,
"spoiler_level", item_path,
1328 {
"none",
"mild",
"major" }, item_result
1330 optional_number_range(
1331 item,
"confidence", item_path, 0.0, 1.0, item_result
1333 validate_evidence_references(
1334 item,
"evidence", item_path, item_result
1341 const json& item,
const std::string_view item_path,
1342 const std::string_view family,
1343 const std::initializer_list<std::string_view> set_fields,
1344 const std::initializer_list<std::string_view> unset_fields,
1347 reject_unknown_fields(
1348 item, item_path, {
"id",
"set",
"unset" }, result
1350 validate_entity_id(item,
"id", item_path, family, result);
1351 const json* set = require_object(item,
"set", item_path, result);
1352 const json* unset = require_array(item,
"unset", item_path, result);
1353 if (set !=
nullptr) {
1354 reject_unknown_fields(*set, child_path(item_path,
"set"),
1355 set_fields, result);
1357 if (unset !=
nullptr) {
1358 const std::string unset_path = child_path(item_path,
"unset");
1359 validate_unique_items(*unset, unset_path, result);
1360 for (std::size_t index = 0; index < unset->size(); ++index) {
1361 const json& value = (*unset)[index];
1362 if (!value.is_string()
1363 || std::ranges::find(
1367 ? value.get_ref<
const std::string&>()
1371 == unset_fields.end()) {
1372 add(result, unset_path +
"/" + std::to_string(index),
1373 "enum",
"field cannot be removed from this entity");
1377 if (set !=
nullptr && unset !=
nullptr && set->empty()
1378 && unset->empty()) {
1379 add(result, std::string(item_path),
"min_operations",
1380 "update must set or unset at least one field");
1385 const json& set,
const std::string_view path,
1389 set,
"medium", path,
1390 {
"film",
"short_film",
"television",
"novel",
"novella",
1391 "short_story",
"poetry",
"play",
"essay",
"album",
"single",
1392 "composition",
"painting",
"print",
"engraving",
"drawing",
1393 "sculpture",
"installation",
"photography",
"mixed_media" },
1396 validate_year_fields(set, path, {
"year_start",
"year_end" }, result);
1398 set,
"date_precision", path,
1399 {
"year",
"decade",
"approximate",
"range",
"exact" }, result
1401 validate_nonempty_optional_strings(
1403 {
"date_start_text",
"date_end_text",
"date_qualifier",
1404 "language_code",
"country_code",
"production_info_json" },
1410 const json& update,
const std::string_view path,
1413 reject_unknown_fields(
1415 {
"agents",
"works",
"concepts",
"manifestations",
"sources",
1419 validate_optional_object_array(
1420 update,
"agents", path, result,
1421 [](
const json& item,
const std::string& item_path,
1423 validate_update_fields(
1424 item, item_path,
"agent",
1425 {
"birth_year",
"death_year" },
1426 {
"birth_year",
"death_year" }, item_result
1428 if (
const json* set = item.find(
"set") != item.end()
1429 && item[
"set"].is_object()
1432 validate_year_fields(
1433 *set, child_path(item_path,
"set"),
1434 {
"birth_year",
"death_year" }, item_result
1439 validate_optional_object_array(
1440 update,
"works", path, result,
1441 [](
const json& item,
const std::string& item_path,
1443 validate_update_fields(
1444 item, item_path,
"work",
1445 {
"medium",
"year_start",
"year_end",
"date_precision",
1446 "date_start_text",
"date_end_text",
"date_qualifier",
1447 "language_code",
"country_code",
1448 "production_info_json" },
1449 {
"year_start",
"year_end",
"date_precision",
1450 "date_start_text",
"date_end_text",
"date_qualifier",
1451 "language_code",
"country_code",
1452 "production_info_json" },
1455 if (item.contains(
"set") && item[
"set"].is_object()) {
1457 item[
"set"], child_path(item_path,
"set"), item_result
1462 validate_optional_object_array(
1463 update,
"concepts", path, result,
1464 [](
const json& item,
const std::string& item_path,
1466 static const std::regex slug(
1467 R"(^[a-z0-9]+(?:-[a-z0-9]+)*$)"
1469 validate_update_fields(
1470 item, item_path,
"concept", {
"concept_type",
"slug" },
1473 if (item.contains(
"unset") && item[
"unset"].is_array()
1474 && !item[
"unset"].empty()) {
1475 add(item_result, child_path(item_path,
"unset"),
1476 "max_items",
"concept fields cannot be unset");
1478 if (item.contains(
"set") && item[
"set"].is_object()) {
1479 const json& set = item[
"set"];
1481 add(item_result, child_path(item_path,
"set"),
1483 "concept update must set at least one field");
1486 set,
"concept_type", child_path(item_path,
"set"),
1487 {
"genre",
"style",
"theme",
"keyword",
"motif",
1488 "trope",
"phobia",
"taboo",
"technique",
"movement",
1489 "setting",
"mood",
"content_warning" },
1493 set,
"slug", child_path(item_path,
"set"), slug,
1499 validate_optional_object_array(
1500 update,
"manifestations", path, result,
1501 [](
const json& item,
const std::string& item_path,
1503 validate_update_fields(
1504 item, item_path,
"manifestation",
1505 {
"work_id",
"manifestation_type",
"release_year",
1506 "region_code",
"language_code",
"label" },
1507 {
"release_year",
"region_code",
"language_code" },
1510 if (item.contains(
"set") && item[
"set"].is_object()) {
1511 const json& set = item[
"set"];
1512 const std::string set_path = child_path(item_path,
"set");
1513 static const std::regex work_id(R"(^work-[0-9]{6,}$)");
1515 set,
"work_id", set_path, work_id, item_result
1518 set,
"manifestation_type", set_path,
1519 {
"edition",
"translation",
"release",
"pressing",
1520 "cut",
"restoration",
"reissue" },
1523 optional_integer_range(
1524 set,
"release_year", set_path, -9999, 9999, item_result
1526 validate_nonempty_optional_strings(
1528 {
"region_code",
"language_code",
"label" },
1534 validate_optional_object_array(
1535 update,
"sources", path, result,
1536 [](
const json& item,
const std::string& item_path,
1538 reject_unknown_fields(
1539 item, item_path, {
"id",
"set",
"unset" }, item_result
1541 require_integer_range(
1542 item,
"id", item_path, 1,
1543 std::numeric_limits<std::int64_t>::max(), item_result
1546 = require_object(item,
"set", item_path, item_result);
1548 = require_array(item,
"unset", item_path, item_result);
1549 const std::string set_path = child_path(item_path,
"set");
1550 if (set !=
nullptr) {
1551 reject_unknown_fields(
1553 {
"source_type",
"title",
"bibliography_text",
1554 "author_text",
"publisher",
"publication_date",
"url",
1555 "doi",
"isbn",
"language_code" },
1559 *set,
"source_type", set_path,
1560 {
"book",
"article",
"catalogue",
"web_page",
1561 "interview",
"database",
"video",
"audio",
"PDF" },
1564 validate_nonempty_optional_strings(
1566 {
"title",
"bibliography_text",
"author_text",
1567 "publisher",
"publication_date",
"url",
"doi",
1568 "isbn",
"language_code" },
1572 if (unset !=
nullptr) {
1573 const std::string unset_path
1574 = child_path(item_path,
"unset");
1575 validate_unique_items(*unset, unset_path, item_result);
1576 for (std::size_t index = 0; index < unset->size();
1578 const json& value = (*unset)[index];
1579 constexpr std::array<std::string_view, 9> choices {
1580 "title",
"bibliography_text",
"author_text",
1581 "publisher",
"publication_date",
"url",
"doi",
1582 "isbn",
"language_code"
1584 if (!value.is_string()
1585 || std::ranges::find(
1589 ? value.get_ref<
const std::string&>()
1595 unset_path +
"/" + std::to_string(index),
1597 "field cannot be removed from a source");
1601 if (set !=
nullptr && unset !=
nullptr && set->empty()
1602 && unset->empty()) {
1603 add(item_result, item_path,
"min_operations",
1604 "update must set or unset at least one field");
1608 if (
const json* deletes
1609 = optional_object(update,
"delete", path, result)) {
1610 const std::string delete_path = child_path(path,
"delete");
1611 reject_unknown_fields(
1612 *deletes, delete_path,
1613 {
"names",
"external_ids",
"credits",
"measurements",
1614 "financial_facts",
"evidence",
"work_concepts",
1615 "concept_relations",
"parent_guide_assertions",
1619 for (
const std::string_view key :
1620 {
"names",
"external_ids",
"credits",
"measurements",
1621 "financial_facts",
"evidence",
"work_concepts",
1622 "concept_relations",
"parent_guide_assertions" }) {
1623 validate_positive_id_array(
1624 *deletes, key, delete_path, result
1627 if (
const json* issues = optional_array(
1628 *deletes,
"ingest_issues", delete_path, result
1630 const std::string issues_path
1631 = child_path(delete_path,
"ingest_issues");
1632 validate_unique_items(*issues, issues_path, result);
1633 for (std::size_t index = 0; index < issues->size(); ++index) {
1634 const json& issue = (*issues)[index];
1635 const std::string issue_path
1636 = issues_path +
"/" + std::to_string(index);
1637 if (!issue.is_object()) {
1638 add(result, issue_path,
"type",
1639 "expected an ingest issue key object");
1642 reject_unknown_fields(
1644 {
"batch_id",
"code",
"json_path" }, result
1647 issue,
"batch_id", issue_path, result
1649 require_string(issue,
"code", issue_path, result);
1650 if (
const json* json_path = require_string(
1651 issue,
"json_path", issue_path, result
1653 json_path !=
nullptr
1654 && !json_path->get_ref<
const std::string&>()
1655 .starts_with(
'/')) {
1657 result, child_path(issue_path,
"json_path"),
1658 "pattern",
"JSON Pointer must start with '/'"
1667 const json& merge,
const std::string_view path,
1670 reject_unknown_fields(
1671 merge, path, {
"agents",
"works",
"concepts" }, result
1673 const auto validate_merge = [&merge, path, &result](
1674 const std::string_view key,
1675 const std::string_view family,
1676 const auto& validate_set,
1677 const auto set_fields,
1678 const auto unset_fields,
1679 const bool unset_forbidden) {
1680 validate_optional_object_array(
1681 merge, key, path, result,
1682 [family, &validate_set, set_fields, unset_fields,
1684 const json& item,
const std::string& item_path,
1687 reject_unknown_fields(
1689 {
"target",
"members",
"set",
"unset" }, item_result
1692 item,
"target", item_path, family, item_result
1695 = require_array(item,
"members", item_path, item_result);
1697 = require_object(item,
"set", item_path, item_result);
1699 = require_array(item,
"unset", item_path, item_result);
1700 if (members !=
nullptr) {
1701 const std::string members_path
1702 = child_path(item_path,
"members");
1703 if (members->empty()) {
1704 add(item_result, members_path,
"min_items",
1705 "merge requires at least one member");
1707 validate_unique_items(
1708 *members, members_path, item_result
1710 const std::regex identifier(
1711 "^" + std::string(family) + R"(-[0-9]{6,}$)"
1713 for (std::size_t index = 0; index < members->size();
1715 const json& value = (*members)[index];
1716 if (!value.is_string()
1717 || !std::regex_match(
1719 ? value.get_ref<
const std::string&>()
1725 + std::to_string(index),
1727 "member uses the wrong canonical family");
1731 if (set !=
nullptr) {
1732 const std::string set_path
1733 = child_path(item_path,
"set");
1734 reject_unknown_fields(
1735 *set, set_path, set_fields, item_result
1737 validate_set(*set, set_path, item_result);
1739 if (unset !=
nullptr) {
1740 const std::string unset_path
1741 = child_path(item_path,
"unset");
1742 validate_unique_items(*unset, unset_path, item_result);
1743 if (unset_forbidden && !unset->empty()) {
1744 add(item_result, unset_path,
"max_items",
1745 "fields cannot be unset for this family");
1747 for (std::size_t index = 0; index < unset->size();
1749 const json& value = (*unset)[index];
1750 if (!unset_forbidden
1751 && (!value.is_string()
1752 || std::ranges::find(
1757 const std::string&>()
1761 == unset_fields.end())) {
1764 + std::to_string(index),
1766 "field cannot be removed during merge");
1776 [](
const json& set,
const std::string& set_path,
1777 validation_result& item_result) {
1779 set,
"agent_type", set_path,
1780 {
"person",
"organization",
"group" }, item_result
1782 validate_year_fields(
1783 set, set_path, {
"birth_year",
"death_year" }, item_result
1786 std::initializer_list<std::string_view> {
1787 "agent_type",
"birth_year",
"death_year"
1789 std::initializer_list<std::string_view> {
1790 "birth_year",
"death_year"
1796 [](
const json& set,
const std::string& set_path,
1797 validation_result& item_result) {
1798 validate_work_set(set, set_path, item_result);
1800 std::initializer_list<std::string_view> {
1801 "medium",
"year_start",
"year_end",
"date_precision",
1802 "date_start_text",
"date_end_text",
"date_qualifier",
1803 "language_code",
"country_code",
"production_info_json"
1805 std::initializer_list<std::string_view> {
1806 "year_start",
"year_end",
"date_precision",
"date_start_text",
1807 "date_end_text",
"date_qualifier",
"language_code",
1808 "country_code",
"production_info_json"
1813 "concepts",
"concept",
1814 [](
const json& set,
const std::string& set_path,
1815 validation_result& item_result) {
1816 static const std::regex slug(
1817 R"(^[a-z0-9]+(?:-[a-z0-9]+)*$)"
1820 set,
"concept_type", set_path,
1821 {
"genre",
"style",
"theme",
"keyword",
"motif",
"trope",
1822 "phobia",
"taboo",
"technique",
"movement",
"setting",
1823 "mood",
"content_warning" },
1827 set,
"slug", set_path, slug, item_result
1830 std::initializer_list<std::string_view> {
"concept_type",
"slug" },
1831 std::initializer_list<std::string_view> {},
1838 reject_unknown_fields(
1840 {
"format",
"batch_id",
"create",
"update",
"merge" }, result
1842 const json* format = require_string(document,
"format",
"", result);
1843 if (format !=
nullptr && *format !=
"arachne_batch_v2") {
1844 add(result,
"/format",
"const",
1845 "format must be arachne_batch_v2");
1847 require_stable_id(document,
"batch_id",
"", result);
1848 if (
const json* create
1849 = require_object(document,
"create",
"", result)) {
1850 validate_create_operations(*create,
"/create", result);
1852 if (
const json* update
1853 = require_object(document,
"update",
"", result)) {
1854 validate_update_operations(*update,
"/update", result);
1856 if (
const json* merge
1857 = require_object(document,
"merge",
"", result)) {
1858 validate_merge_operations(*merge,
"/merge", result);
1864 reject_unknown_fields(
1866 {
"contract",
"format_version",
"envelope_id",
"payload_ref",
1867 "payload_sha256",
"submission_ref",
"status",
"accepted_by",
1868 "supersedes",
"extensions" },
1871 require_stable_id(document,
"envelope_id",
"", result);
1872 require_string(document,
"payload_ref",
"", result);
1873 require_sha256(document,
"payload_sha256",
"", result);
1874 require_string(document,
"submission_ref",
"", result);
1876 document,
"status",
"",
1877 {
"received",
"needs_format_fix",
"waiting_approval",
"accepted",
1878 "waiting_processing",
"processing",
"integrated",
"failed",
1879 "rejected",
"superseded" },
1882 optional_string(document,
"accepted_by",
"", result);
1883 optional_string(document,
"supersedes",
"", result);
1884 validate_extensions(document,
"", result);
1888 reject_unknown_fields(
1890 {
"contract",
"format_version",
"plan_id",
"source",
"requests",
1891 "created_at",
"extensions" },
1894 require_stable_id(document,
"plan_id",
"", result);
1895 require_string(document,
"source",
"", result);
1896 require_timestamp(document,
"created_at",
"", result);
1897 const json* requests = require_array(document,
"requests",
"", result);
1898 if (requests !=
nullptr && requests->empty()) {
1899 add(result,
"/requests",
"min_items",
1900 "a fetch plan must contain at least one request");
1902 if (requests !=
nullptr) {
1903 for (std::size_t index = 0; index < requests->size(); ++index) {
1904 const std::string path =
"/requests/" + std::to_string(index);
1905 const json& request = (*requests)[index];
1906 if (!request.is_object()) {
1907 add(result, path,
"type",
"expected a request object");
1910 reject_unknown_fields(
1912 {
"request_id",
"locator",
"purpose",
"entities",
"fields",
1913 "pages",
"archives",
"follow_up" },
1916 require_stable_id(request,
"request_id", path, result);
1917 require_string(request,
"locator", path, result);
1918 require_string(request,
"purpose", path, result);
1921 validate_extensions(document,
"", result);
1926 reject_unknown_fields(
1928 {
"contract",
"format_version",
"request_id",
1929 "door_id",
"endpoint_id",
"operation",
1930 "freshness_policy",
"idempotency_key",
"plan_id",
1931 "locator",
"method",
"headers",
1932 "pagination",
"retry",
"expected",
1933 "redirect_policy",
"output_ref",
"body_artifact",
1934 "resume_artifact",
"extensions" },
1937 require_stable_id(document,
"request_id",
"", result);
1938 if (document.contains(
"door_id")) {
1939 require_stable_id(document,
"door_id",
"", result);
1941 if (document.contains(
"endpoint_id")) {
1942 require_stable_id(document,
"endpoint_id",
"", result);
1944 if (document.contains(
"operation")) {
1946 document,
"operation",
"",
1947 {
"bulk_snapshot",
"incremental_harvest",
"point_lookup",
1948 "resume_download",
"backend_read",
"external_write" },
1952 if (document.contains(
"freshness_policy")) {
1954 document,
"freshness_policy",
"",
1955 {
"fresh_required",
"cache_allowed",
"stale_allowed",
1960 optional_string(document,
"idempotency_key",
"", result);
1961 optional_string(document,
"plan_id",
"", result);
1962 require_string(document,
"locator",
"", result);
1963 require_enum(document,
"method",
"", {
"GET",
"POST" }, result);
1964 require_string(document,
"output_ref",
"", result);
1966 optional_object(document,
"headers",
"", result);
1967 if (
const json* pagination
1968 = optional_object(document,
"pagination",
"", result)) {
1969 reject_unknown_fields(
1970 *pagination,
"/pagination", {
"mode" }, result
1973 *pagination,
"mode",
"/pagination",
1974 {
"none",
"link_header",
"cursor",
"page_number" }, result
1977 if (
const json* retry
1978 = optional_object(document,
"retry",
"", result)) {
1979 reject_unknown_fields(
1981 {
"maximum_attempts",
"initial_delay_ms",
"maximum_delay_ms",
1982 "total_delay_budget_ms",
"respect_retry_after" },
1985 optional_bounded_integer(
1986 *retry,
"maximum_attempts",
"/retry", 1, 20, result
1988 optional_bounded_integer(
1989 *retry,
"initial_delay_ms",
"/retry", 0, 60'000, result
1991 optional_bounded_integer(
1992 *retry,
"maximum_delay_ms",
"/retry", 0, 3'600'000, result
1994 optional_bounded_integer(
1995 *retry,
"total_delay_budget_ms",
"/retry", 0, 3'600'000, result
1997 if (
const auto value = retry->find(
"respect_retry_after");
1998 value != retry->end() && !value->is_boolean()) {
1999 add(result,
"/retry/respect_retry_after",
"type",
2000 "expected a boolean");
2003 if (
const json* expected
2004 = optional_object(document,
"expected",
"", result)) {
2005 reject_unknown_fields(
2006 *expected,
"/expected",
2007 {
"maximum_bytes",
"timeout_ms",
"connect_timeout_ms",
2008 "read_timeout_ms",
"write_timeout_ms",
"sha256" },
2011 optional_bounded_integer(
2012 *expected,
"maximum_bytes",
"/expected", 1,
2013 1'099'511'627'776ULL, result
2015 optional_bounded_integer(
2016 *expected,
"timeout_ms",
"/expected", 1, 86'400'000, result
2018 optional_bounded_integer(
2019 *expected,
"connect_timeout_ms",
"/expected", 1, 86'400'000,
2022 optional_bounded_integer(
2023 *expected,
"read_timeout_ms",
"/expected", 1, 86'400'000, result
2025 optional_bounded_integer(
2026 *expected,
"write_timeout_ms",
"/expected", 1, 86'400'000,
2029 if (expected->contains(
"sha256")) {
2030 require_sha256(*expected,
"sha256",
"/expected", result);
2033 if (
const json* redirect
2034 = optional_object(document,
"redirect_policy",
"", result)) {
2035 reject_unknown_fields(
2036 *redirect,
"/redirect_policy",
2037 {
"follow",
"maximum_redirects",
"allow_https_to_http",
2041 for (
const std::string_view key :
2042 {
"follow",
"allow_https_to_http" }) {
2044 = field(*redirect, key,
"/redirect_policy", result);
2045 if (value !=
nullptr && !value->is_boolean()) {
2046 add(result, child_path(
"/redirect_policy", key),
"type",
2047 "expected a boolean");
2050 field(*redirect,
"maximum_redirects",
"/redirect_policy", result);
2051 optional_bounded_integer(
2052 *redirect,
"maximum_redirects",
"/redirect_policy", 0, 20,
2055 validate_string_array(
2056 *redirect,
"allowed_hosts",
"/redirect_policy",
true, result
2058 const auto hosts = redirect->find(
"allowed_hosts");
2059 if (hosts != redirect->end() && hosts->is_array()) {
2060 static const std::regex host_expression(
2061 R"(^[A-Za-z0-9.-]+(:[0-9]{1,5})?$)"
2063 if (hosts->size() > 128) {
2064 add(result,
"/redirect_policy/allowed_hosts",
"max_items",
2065 "at most 128 allowed hosts may be declared");
2067 std::set<std::string> seen;
2068 for (std::size_t index = 0; index < hosts->size(); ++index) {
2069 const json& host = (*hosts)[index];
2070 if (!host.is_string() || host.empty()) {
2073 const std::string& value
2074 = host.get_ref<
const std::string&>();
2075 const std::string path =
"/redirect_policy/allowed_hosts/"
2076 + std::to_string(index);
2077 if (value.size() > 253
2078 || !std::regex_match(value, host_expression)) {
2079 add(result, path,
"host",
2080 "expected a host or host:port without scheme or "
2083 if (!seen.insert(value).second) {
2084 add(result, path,
"unique",
2085 "allowed hosts must be unique");
2090 if (
const json* body
2091 = optional_object(document,
"body_artifact",
"", result)) {
2092 validate_artifact(*body,
"/body_artifact", result);
2094 if (
const json* partial
2095 = optional_object(document,
"resume_artifact",
"", result)) {
2096 validate_artifact(*partial,
"/resume_artifact", result);
2098 const bool resume_operation
2099 = document.value(
"operation",
"point_lookup") ==
"resume_download";
2100 if (resume_operation && !document.contains(
"resume_artifact")) {
2101 add(result,
"/resume_artifact",
"required",
2102 "resume_download requires a partial artifact");
2103 }
else if (!resume_operation && document.contains(
"resume_artifact")) {
2104 add(result,
"/resume_artifact",
"operation",
2105 "resume_artifact is reserved for resume_download");
2107 validate_extensions(document,
"", result);
2113 reject_unknown_fields(
2115 {
"contract",
"format_version",
"artifact_id",
"request_id",
2116 "door_id",
"operation",
"source_locator",
"artifact",
"transport",
2117 "response_metadata",
"acquired_at",
"extensions" },
2120 require_stable_id(document,
"artifact_id",
"", result);
2121 require_stable_id(document,
"request_id",
"", result);
2122 if (document.contains(
"door_id")) {
2123 require_stable_id(document,
"door_id",
"", result);
2125 if (document.contains(
"operation")) {
2127 document,
"operation",
"",
2128 {
"bulk_snapshot",
"incremental_harvest",
"point_lookup",
2129 "resume_download",
"backend_read",
"external_write" },
2133 require_string(document,
"source_locator",
"", result);
2134 require_timestamp(document,
"acquired_at",
"", result);
2135 const json* transport
2136 = require_object(document,
"transport",
"", result);
2137 bool delivered =
false;
2138 if (transport !=
nullptr) {
2139 reject_unknown_fields(
2140 *transport,
"/transport",
2141 {
"status",
"attempts",
"delivery_mode",
"retry_after_ms",
2142 "error_code",
"error_message" },
2146 = require_string(*transport,
"status",
"/transport", result);
2147 delivered = status !=
nullptr && *status ==
"delivered";
2148 if (status !=
nullptr
2149 && !string_is_one_of(status, {
"delivered",
"failed" })) {
2150 add(result,
"/transport/status",
"enum",
2151 "transport status must be delivered or failed");
2153 require_nonnegative_integer(
2154 *transport,
"attempts",
"/transport", result
2156 if (transport->contains(
"delivery_mode")) {
2158 *transport,
"delivery_mode",
"/transport",
2159 {
"fetched",
"cache_validated",
"stale",
"resumed",
2164 if (transport->contains(
"retry_after_ms")) {
2165 require_nonnegative_integer(
2166 *transport,
"retry_after_ms",
"/transport", result
2169 optional_string(*transport,
"error_code",
"/transport", result);
2170 optional_string(*transport,
"error_message",
"/transport", result);
2172 const json* artifact
2173 = optional_object(document,
"artifact",
"", result);
2174 if (delivered && artifact ==
nullptr) {
2175 add(result,
"/artifact",
"required_on_success",
2176 "delivered transport requires an artifact reference");
2178 if (artifact !=
nullptr) {
2179 validate_artifact(*artifact,
"/artifact", result);
2181 if (
const json* response
2182 = require_object(document,
"response_metadata",
"", result)) {
2183 reject_unknown_fields(
2184 *response,
"/response_metadata",
2185 {
"status_code",
"effective_url",
"headers",
"redirect_chain",
2186 "started_at",
"completed_at" },
2189 require_nonnegative_integer(
2190 *response,
"status_code",
"/response_metadata", result
2192 optional_bounded_integer(
2193 *response,
"status_code",
"/response_metadata", 0, 999, result
2196 *response,
"effective_url",
"/response_metadata", result
2198 if (
const json* headers = require_array(
2199 *response,
"headers",
"/response_metadata", result
2201 if (headers->size() > 1'024U) {
2202 add(result,
"/response_metadata/headers",
"max_items",
2203 "response metadata has too many headers");
2205 for (std::size_t index = 0; index < headers->size(); ++index) {
2206 const std::string path
2207 =
"/response_metadata/headers/" + std::to_string(index);
2208 const json& header = headers->at(index);
2209 if (!header.is_object()) {
2210 add(result, path,
"type",
"expected a header object");
2213 reject_unknown_fields(
2214 header, path, {
"name",
"value" }, result
2216 require_string(header,
"name", path, result);
2217 const json* value = field(header,
"value", path, result);
2218 if (value !=
nullptr && !value->is_string()) {
2219 add(result, path +
"/value",
"type",
2220 "expected a JSON string");
2224 validate_string_array(
2225 *response,
"redirect_chain",
"/response_metadata",
false, result
2227 if (
const auto chain = response->find(
"redirect_chain");
2228 chain != response->end() && chain->is_array()
2229 && chain->size() > 20U) {
2230 add(result,
"/response_metadata/redirect_chain",
"max_items",
2231 "response metadata has too many redirects");
2234 *response,
"started_at",
"/response_metadata", result
2237 *response,
"completed_at",
"/response_metadata", result
2240 validate_extensions(document,
"", result);
2245 reject_unknown_fields(
2247 {
"contract",
"format_version",
"plan_id",
"source_snapshot",
2248 "product_snapshot",
"algorithm_version",
"configuration",
2249 "plan_artifact",
"summary",
"created_at",
"extensions" },
2252 require_stable_id(document,
"plan_id",
"", result);
2253 require_string(document,
"algorithm_version",
"", result);
2254 require_timestamp(document,
"created_at",
"", result);
2256 = require_object(document,
"source_snapshot",
"", result);
2257 if (source !=
nullptr) {
2258 reject_unknown_fields(
2259 *source,
"/source_snapshot",
2260 {
"snapshot_id",
"storage_ref",
"sha256" }, result
2263 *source,
"snapshot_id",
"/source_snapshot", result
2265 require_string(*source,
"storage_ref",
"/source_snapshot", result);
2266 require_sha256(*source,
"sha256",
"/source_snapshot", result);
2269 = require_object(document,
"product_snapshot",
"", result);
2270 if (product !=
nullptr) {
2271 reject_unknown_fields(
2272 *product,
"/product_snapshot", {
"snapshot_id",
"sha256" },
2276 *product,
"snapshot_id",
"/product_snapshot", result
2278 require_sha256(*product,
"sha256",
"/product_snapshot", result);
2280 const json* configuration
2281 = require_object(document,
"configuration",
"", result);
2282 if (configuration !=
nullptr) {
2283 reject_unknown_fields(
2284 *configuration,
"/configuration", {
"sha256",
"values" }, result
2286 require_sha256(*configuration,
"sha256",
"/configuration", result);
2287 require_object(*configuration,
"values",
"/configuration", result);
2289 validate_artifact_field(document,
"plan_artifact",
"", result);
2290 const json* summary = require_object(document,
"summary",
"", result);
2291 if (summary !=
nullptr) {
2292 reject_unknown_fields(
2293 *summary,
"/summary",
2294 {
"candidate_count",
"edge_count",
"group_count" }, result
2296 require_nonnegative_integer(
2297 *summary,
"candidate_count",
"/summary", result
2299 require_nonnegative_integer(
2300 *summary,
"edge_count",
"/summary", result
2302 require_nonnegative_integer(
2303 *summary,
"group_count",
"/summary", result
2306 validate_extensions(document,
"", result);
2310 const json& value,
const std::string_view path,
2313 reject_unknown_fields(value, path, {
"passed",
"report" }, result);
2314 const json* passed = field(value,
"passed", path, result);
2315 if (passed !=
nullptr && !passed->is_boolean()) {
2316 add(result, child_path(path,
"passed"),
"type",
2317 "expected a boolean");
2319 validate_artifact_field(value,
"report", path, result);
2323 const json& document,
2324 const std::initializer_list<std::string_view> allowed,
2327 reject_unknown_fields(document,
"", allowed, result);
2328 require_stable_id(document,
"snapshot_id",
"", result);
2329 require_stable_id(document,
"run_id",
"", result);
2330 require_string(document,
"graph_version",
"", result);
2331 require_sha256(document,
"content_sha256",
"", result);
2332 validate_artifact_field(document,
"database",
"", result);
2333 validate_artifact_array(document,
"exports",
"", result);
2334 require_timestamp(document,
"activated_at",
"", result);
2335 const json* validation
2336 = require_object(document,
"structural_validation",
"", result);
2337 if (validation !=
nullptr) {
2338 validate_structural_validation(
2339 *validation,
"/structural_validation", result
2342 validate_extensions(document,
"", result);
2347 validate_snapshot_common(
2349 {
"contract",
"format_version",
"snapshot_id",
"run_id",
2350 "graph_version",
"content_sha256",
"database",
"exports",
2351 "cocoon_ids",
"activated_at",
"structural_validation",
2355 if (document.contains(
"cocoon_ids")) {
2356 validate_string_array(document,
"cocoon_ids",
"",
false, result);
2363 validate_snapshot_common(
2365 {
"contract",
"format_version",
"snapshot_id",
"run_id",
2366 "graph_version",
"content_sha256",
"database",
"exports",
2367 "plan_id",
"source_snapshot_id",
"activated_at",
2368 "structural_validation",
"extensions" },
2371 require_stable_id(document,
"plan_id",
"", result);
2372 require_stable_id(document,
"source_snapshot_id",
"", result);
2378 reject_unknown_fields(
2380 {
"contract",
"format_version",
"projection_id",
2381 "product_snapshot_id",
"candidate_snapshot_id",
2382 "projection_version",
"settings_sha256",
"projection",
2383 "edge_semantics",
"generated_at",
"extensions" },
2386 require_stable_id(document,
"projection_id",
"", result);
2387 require_stable_id(document,
"product_snapshot_id",
"", result);
2388 optional_string(document,
"candidate_snapshot_id",
"", result);
2389 require_string(document,
"projection_version",
"", result);
2390 require_sha256(document,
"settings_sha256",
"", result);
2391 validate_artifact_field(document,
"projection",
"", result);
2392 require_timestamp(document,
"generated_at",
"", result);
2393 const json* semantics
2394 = require_object(document,
"edge_semantics",
"", result);
2395 if (semantics !=
nullptr) {
2396 reject_unknown_fields(
2397 *semantics,
"/edge_semantics",
2398 {
"human_type",
"derived_types" }, result
2400 require_string(*semantics,
"human_type",
"/edge_semantics", result);
2401 validate_string_array(
2402 *semantics,
"derived_types",
"/edge_semantics",
true, result
2405 validate_extensions(document,
"", result);
2409 reject_unknown_fields(
2411 {
"contract",
"format_version",
"bundle_id",
"projection_id",
2412 "product_snapshot_id",
"candidate_snapshot_id",
"viewer_version",
2413 "entrypoint",
"bundle",
"generated_at",
"extensions" },
2416 require_stable_id(document,
"bundle_id",
"", result);
2417 require_stable_id(document,
"projection_id",
"", result);
2418 require_stable_id(document,
"product_snapshot_id",
"", result);
2419 optional_string(document,
"candidate_snapshot_id",
"", result);
2420 require_string(document,
"viewer_version",
"", result);
2421 require_string(document,
"entrypoint",
"", result);
2422 validate_artifact_field(document,
"bundle",
"", result);
2423 require_timestamp(document,
"generated_at",
"", result);
2424 validate_extensions(document,
"", result);
2433 validate_arachne_batch(document, result);
2436 validate_batch_envelope(document, result);
2439 validate_fetch_plan(document, result);
2442 validate_fetch_request(document, result);
2445 validate_acquired_artifact(document, result);
2448 validate_candidate_plan(document, result);
2451 validate_product_snapshot(document, result);
2454 validate_candidate_snapshot(document, result);
2457 validate_viewer_projection(document, result);
2460 validate_site_bundle(document, result);
2466 if (value.is_discarded()) {
2467 throw std::invalid_argument(
"discarded JSON value at " + path);
2469 if (value.is_number_float() && !std::isfinite(value.get<
double>())) {
2470 throw std::invalid_argument(
"non-finite number at " + path);
2472 if (value.is_array()) {
2473 for (std::size_t index = 0; index < value.size(); ++index) {
2475 value[index], path +
"/" + std::to_string(index)
2478 }
else if (value.is_object()) {
2479 for (
const auto& [key, child] : value.items()) {
2480 reject_non_finite(child, child_path(path, key));
2489 = std::ranges::find_if(contract_names, [name](
const auto& item) {
2490 return item.second == name;
2492 return it == contract_names.end() ? std::string_view {} : it->first;
2498 = std::ranges::find_if(contract_names, [name](
const auto& item) {
2499 return item.first == name;
2501 if (it == contract_names.end()) {
2502 return std::nullopt;
2527 const auto name = inspect_contract(document, result);
2528 if (!name.has_value()) {
2531 validate_header(*name, document, result);
2532 validate_body(*name, document, result);
2538 if (!document.is_object()) {
2539 add(result,
"",
"type",
"contract document must be a JSON object");
2542 validate_header(expected, document, result);
2543 validate_body(expected, document, result);
2548 reject_non_finite(document,
"");
2549 return document.dump(
2550 -1,
' ',
false, nlohmann::json::error_handler_t::strict
void require_timestamp(const json &object, const std::string_view key, const std::string_view path, validation_result &result)
std::optional< unsigned int > declared_major(const std::string_view name)
std::optional< contract_name > inspect_contract(const json &document, validation_result &result)
const json * field(const json &object, const std::string_view key, const std::string_view path, validation_result &result)
void require_boolean(const json &object, const std::string_view key, const std::string_view path, validation_result &result)
void require_nonnegative_integer(const json &object, const std::string_view key, const std::string_view path, validation_result &result)
void validate_positive_id_array(const json &object, const std::string_view key, const std::string_view path, validation_result &result)
void optional_integer_range(const json &object, const std::string_view key, const std::string_view path, const std::int64_t minimum, const std::int64_t maximum, validation_result &result)
bool string_is_one_of(const json *value, const std::initializer_list< std::string_view > choices)
const json * require_string(const json &object, const std::string_view key, const std::string_view path, validation_result &result)
void validate_artifact(const json &artifact, const std::string_view path, validation_result &result)
void validate_string_array(const json &object, const std::string_view key, const std::string_view path, const bool require_nonempty, validation_result &result)
const json * require_object(const json &object, const std::string_view key, const std::string_view path, validation_result &result)
void validate_fetch_request(const json &document, validation_result &result)
void validate_work_set(const json &set, const std::string_view path, validation_result &result)
void require_enum(const json &object, const std::string_view key, const std::string_view path, const std::initializer_list< std::string_view > choices, validation_result &result)
void reject_non_finite(const json &value, const std::string &path)
bool matches(const json *value, const std::regex &expression)
void validate_artifact_array(const json &object, const std::string_view key, const std::string_view path, validation_result &result)
void require_sha256(const json &object, const std::string_view key, const std::string_view path, validation_result &result)
void optional_number_range(const json &object, const std::string_view key, const std::string_view path, const double minimum, const double maximum, validation_result &result)
void validate_candidate_snapshot(const json &document, validation_result &result)
constexpr std::array< std::pair< std::string_view, contract_name >, 10 > contract_names
void validate_entity_id(const json &object, const std::string_view key, const std::string_view path, const std::string_view family, validation_result &result)
void validate_unique_items(const json &array, const std::string_view path, validation_result &result)
bool known_contract_base(const std::string_view name)
void validate_database_or_local_reference(const json &value, const std::string_view path, validation_result &result)
void validate_body(const contract_name name, const json &document, validation_result &result)
void require_stable_id(const json &object, const std::string_view key, const std::string_view path, validation_result &result)
void validate_arachne_batch(const json &document, validation_result &result)
void optional_enum(const json &object, const std::string_view key, const std::string_view path, const std::initializer_list< std::string_view > choices, validation_result &result)
void validate_optional_object_array(const json &object, const std::string_view key, const std::string_view path, validation_result &result, Validator &&validator)
void validate_acquired_artifact(const json &document, validation_result &result)
void validate_viewer_projection(const json &document, validation_result &result)
void validate_site_bundle(const json &document, validation_result &result)
const json * optional_string(const json &object, const std::string_view key, const std::string_view path, validation_result &result)
void validate_evidence_references(const json &object, const std::string_view key, const std::string_view path, validation_result &result)
void require_integer_range(const json &object, const std::string_view key, const std::string_view path, const std::int64_t minimum, const std::int64_t maximum, validation_result &result)
void validate_artifact_field(const json &object, const std::string_view key, const std::string_view path, validation_result &result)
void validate_create_operations(const json &create, const std::string_view path, validation_result &result)
void validate_snapshot_common(const json &document, const std::initializer_list< std::string_view > allowed, validation_result &result)
void validate_batch_envelope(const json &document, validation_result &result)
void validate_fetch_plan(const json &document, validation_result &result)
void require_pattern(const json &object, const std::string_view key, const std::string_view path, const std::regex &expression, validation_result &result)
void optional_bounded_integer(const json &object, const std::string_view key, const std::string_view path, const std::uint64_t minimum, const std::uint64_t maximum, validation_result &result)
std::string child_path(const std::string_view parent, const std::string_view key)
void validate_header(const contract_name expected, const json &document, validation_result &result)
void validate_update_fields(const json &item, const std::string_view item_path, const std::string_view family, const std::initializer_list< std::string_view > set_fields, const std::initializer_list< std::string_view > unset_fields, validation_result &result)
const json * require_array(const json &object, const std::string_view key, const std::string_view path, validation_result &result)
void reject_unknown_fields(const json &object, const std::string_view path, const std::initializer_list< std::string_view > allowed, validation_result &result)
void validate_nonempty_optional_strings(const json &object, const std::string_view path, const std::initializer_list< std::string_view > keys, validation_result &result)
const json * optional_object(const json &object, const std::string_view key, const std::string_view path, validation_result &result)
void validate_year_fields(const json &object, const std::string_view path, const std::initializer_list< std::string_view > keys, validation_result &result)
void validate_stable_reference(const json &value, const std::string_view path, validation_result &result)
const json * optional_array(const json &object, const std::string_view key, const std::string_view path, validation_result &result)
void validate_merge_operations(const json &merge, const std::string_view path, validation_result &result)
void optional_pattern(const json &object, const std::string_view key, const std::string_view path, const std::regex &expression, validation_result &result)
void validate_extensions(const json &object, const std::string_view path, validation_result &result)
void add(validation_result &result, std::string path, std::string code, std::string message)
void validate_structural_validation(const json &value, const std::string_view path, validation_result &result)
void validate_candidate_plan(const json &document, validation_result &result)
void validate_product_snapshot(const json &document, validation_result &result)
void require_number_range(const json &object, const std::string_view key, const std::string_view path, const double minimum, const double maximum, validation_result &result)
void validate_update_operations(const json &update, const std::string_view path, validation_result &result)
bool is_artifact_bearing(contract_name name) noexcept
validation_result validate(contract_name expected, const nlohmann::json &document)
validation_result validate(const nlohmann::json &document)
std::optional< contract_name > parse_contract_name(std::string_view name) noexcept
@ research_candidate_graph_plan
@ research_candidate_graph_snapshot
std::string_view to_string(contract_name name) noexcept
std::string canonical_json(const nlohmann::json &document)