Arachne 1.0
Arachne - the perpetual stitcher of Wikidata entities.
Loading...
Searching...
No Matches
store.cpp
Go to the documentation of this file.
1/*
2 * Copyright (c) 2026 Yaroslav Riabtsev
3 * SPDX-License-Identifier: MIT
4 */
5
6#include "penelope/store.hpp"
7
8#include "arachne/contracts.hpp"
9#include "arachne/crypto.hpp"
10
11#include <nlohmann/json.hpp>
12#include <sqlite3.h>
13
14#include <algorithm>
15#include <array>
16#include <cctype>
17#include <cmath>
18#include <cstdint>
19#include <cstdio>
20#include <ctime>
21#include <fstream>
22#include <initializer_list>
23#include <map>
24#include <optional>
25#include <set>
26#include <sstream>
27#include <string_view>
28#include <system_error>
29#include <unordered_map>
30#include <unordered_set>
31#include <utility>
32
33namespace arachne::penelope {
34namespace {
35
36 using json = nlohmann::json;
37 namespace fs = std::filesystem;
38
40 = "research_candidate_graph_snapshot_v1";
41
42 [[noreturn]] void fail(std::string message) {
43 throw store_error(std::move(message));
44 }
45
46 std::string sqlite_message(sqlite3* db, std::string_view operation) {
47 return std::string(operation) + ": "
48 + (db == nullptr ? "unknown SQLite error" : sqlite3_errmsg(db));
49 }
50
51 class statement final {
52 public:
53 statement(sqlite3* db, std::string_view sql)
54 : db_(db) {
55 if (sqlite3_prepare_v2(
56 db_, sql.data(), static_cast<int>(sql.size()), &value_,
57 nullptr
58 )
59 != SQLITE_OK) {
60 fail(sqlite_message(db_, "prepare statement"));
61 }
62 }
63
64 ~statement() { sqlite3_finalize(value_); }
65
66 statement(const statement&) = delete;
67 statement& operator=(const statement&) = delete;
68
69 void text(int index, std::string_view value) {
70 if (sqlite3_bind_text(
71 value_, index, value.data(), static_cast<int>(value.size()),
72 SQLITE_TRANSIENT
73 )
74 != SQLITE_OK) {
75 fail(sqlite_message(db_, "bind text"));
76 }
77 }
78
79 void integer(int index, sqlite3_int64 value) {
80 if (sqlite3_bind_int64(value_, index, value) != SQLITE_OK) {
81 fail(sqlite_message(db_, "bind integer"));
82 }
83 }
84
85 void real(int index, double value) {
86 if (!std::isfinite(value)
87 || sqlite3_bind_double(value_, index, value) != SQLITE_OK) {
88 fail(sqlite_message(db_, "bind real"));
89 }
90 }
91
92 void null(int index) {
93 if (sqlite3_bind_null(value_, index) != SQLITE_OK) {
94 fail(sqlite_message(db_, "bind null"));
95 }
96 }
97
98 void optional_real(int index, const std::optional<double>& value) {
99 value ? real(index, *value) : null(index);
100 }
101
102 [[nodiscard]] bool row() {
103 const int rc = sqlite3_step(value_);
104 if (rc == SQLITE_ROW) {
105 return true;
106 }
107 if (rc == SQLITE_DONE) {
108 return false;
109 }
110 fail(sqlite_message(db_, "execute statement"));
111 }
112
113 void done() {
114 if (sqlite3_step(value_) != SQLITE_DONE) {
115 fail(sqlite_message(db_, "execute statement"));
116 }
117 }
118
119 [[nodiscard]] sqlite3_stmt* get() const noexcept { return value_; }
120
121 private:
122 sqlite3* db_ {};
123 sqlite3_stmt* value_ {};
124 };
125
127
128 std::string sqlite_immutable_uri(const fs::path& path) {
129 static constexpr char hex[] = "0123456789ABCDEF";
130 const std::string native
131 = fs::absolute(path).lexically_normal().generic_string();
132 std::string result = "file:";
133 result.reserve(native.size() + 24U);
134 for (const char raw_value : native) {
135 const auto value = static_cast<unsigned char>(raw_value);
136 if (std::isalnum(value) != 0 || value == '/' || value == ':'
137 || value == '-' || value == '.' || value == '_'
138 || value == '~') {
139 result.push_back(static_cast<char>(value));
140 } else {
141 result.push_back('%');
142 result.push_back(hex[value >> 4U]);
143 result.push_back(hex[value & 0x0FU]);
144 }
145 }
146 result += "?immutable=1";
147 return result;
148 }
149
150 class database final {
151 public:
153 const fs::path& path, int flags,
155 ) {
157 && flags != SQLITE_OPEN_READONLY) {
158 fail("immutable SQLite access must be read-only");
159 }
160 const std::string native
163 : path.string();
165 flags |= SQLITE_OPEN_URI;
166 }
167 if (sqlite3_open_v2(native.c_str(), &value_, flags, nullptr)
168 != SQLITE_OK) {
169 const std::string message
170 = sqlite_message(value_, "open database");
171 if (value_ != nullptr) {
172 sqlite3_close(value_);
173 value_ = nullptr;
174 }
175 fail(message);
176 }
177 sqlite3_busy_timeout(value_, 5000);
178 }
179
181 if (value_ != nullptr) {
182 sqlite3_close(value_);
183 }
184 }
185
186 database(const database&) = delete;
187 database& operator=(const database&) = delete;
188
189 [[nodiscard]] sqlite3* get() const noexcept { return value_; }
190
191 void exec(std::string_view sql) const {
192 char* error = nullptr;
193 const int rc = sqlite3_exec(
194 value_, std::string(sql).c_str(), nullptr, nullptr, &error
195 );
196 if (rc != SQLITE_OK) {
197 std::string message = "execute SQL: ";
198 message += error == nullptr ? sqlite3_errmsg(value_) : error;
199 sqlite3_free(error);
200 fail(std::move(message));
201 }
202 }
203
204 private:
205 sqlite3* value_ {};
206 };
207
208 class transaction final {
209 public:
210 explicit transaction(const database& db)
211 : db_(db) {
212 db_.exec("BEGIN IMMEDIATE");
213 }
214
216 if (!finished_) {
217 try {
218 db_.exec("ROLLBACK");
219 } catch (...) { }
220 }
221 }
222
223 void commit() {
224 db_.exec("COMMIT");
225 finished_ = true;
226 }
227
228 private:
229 const database& db_;
230 bool finished_ { false };
231 };
232
233 struct staging_guard final {
234 fs::path path;
235 bool keep { false };
236
238 if (!keep && path.parent_path().filename() == ".staging"
239 && path.filename().string().starts_with("stage-")) {
240 std::error_code error;
241 fs::remove_all(path, error);
242 }
243 }
244 };
245
246 std::string read_bytes(const fs::path& path) {
247 std::ifstream input(path, std::ios::binary);
248 if (!input) {
249 fail("cannot read file: " + path.string());
250 }
251 std::ostringstream data;
252 data << input.rdbuf();
253 if (!input.eof() && input.fail()) {
254 fail("failed while reading file: " + path.string());
255 }
256 return data.str();
257 }
258
259 json read_json(const fs::path& path) {
260 try {
261 return json::parse(read_bytes(path));
262 } catch (const json::exception& error) {
263 fail("invalid JSON in " + path.string() + ": " + error.what());
264 }
265 }
266
267 void write_bytes(const fs::path& path, std::string_view bytes) {
268 std::ofstream output(path, std::ios::binary | std::ios::trunc);
269 if (!output) {
270 fail("cannot write file: " + path.string());
271 }
272 output.write(bytes.data(), static_cast<std::streamsize>(bytes.size()));
273 output.flush();
274 if (!output) {
275 fail("failed while writing file: " + path.string());
276 }
277 }
278
279 std::string lowercase(std::string value) {
280 std::ranges::transform(value, value.begin(), [](unsigned char c) {
281 return static_cast<char>(std::tolower(c));
282 });
283 return value;
284 }
285
287 return value.size() == 64
288 && std::ranges::all_of(value, [](unsigned char c) {
289 return std::isdigit(c) != 0 || (c >= 'a' && c <= 'f')
290 || (c >= 'A' && c <= 'F');
291 });
292 }
293
295 if (!is_sha256(value)) {
296 fail(std::string(field) + " must be a 64-character SHA-256 digest");
297 }
298 }
299
300 std::string canonical_json(const json& value) {
301 if (value.is_discarded()) {
302 fail("discarded JSON cannot be persisted");
303 }
304 try {
305 return value.dump();
306 } catch (const json::exception& error) {
307 fail(std::string("cannot serialize JSON: ") + error.what());
308 }
309 }
310
311 std::string require_string(
312 const json& object, std::string_view key, std::string_view context
313 ) {
314 const auto it = object.find(std::string(key));
315 if (it == object.end() || !it->is_string()
316 || it->get_ref<const std::string&>().empty()) {
317 fail(
318 std::string(context) + "." + std::string(key)
319 + " must be a non-empty string"
320 );
321 }
322 return it->get<std::string>();
323 }
324
325 std::optional<int> optional_integer(
326 const json& object, std::string_view key, std::string_view context
327 ) {
328 const auto it = object.find(std::string(key));
329 if (it == object.end() || it->is_null()) {
330 return std::nullopt;
331 }
332 if (!it->is_number_integer()) {
333 fail(
334 std::string(context) + "." + std::string(key)
335 + " must be an integer or null"
336 );
337 }
338 return it->get<int>();
339 }
340
341 std::optional<double> optional_number(
342 const json& object, std::string_view key, std::string_view context
343 ) {
344 const auto it = object.find(std::string(key));
345 if (it == object.end() || it->is_null()) {
346 return std::nullopt;
347 }
348 if (!it->is_number()) {
349 fail(
350 std::string(context) + "." + std::string(key)
351 + " must be a number or null"
352 );
353 }
354 const double result = it->get<double>();
355 if (!std::isfinite(result)) {
356 fail(
357 std::string(context) + "." + std::string(key)
358 + " must be finite"
359 );
360 }
361 return result;
362 }
363
365 const json& object, std::string_view key, std::string_view context
366 ) {
367 static const json empty = json::array();
368 const auto it = object.find(std::string(key));
369 if (it == object.end()) {
370 return empty;
371 }
372 if (!it->is_array()) {
373 fail(
374 std::string(context) + "." + std::string(key)
375 + " must be an array"
376 );
377 }
378 return *it;
379 }
380
381 void configure_connection(const database& db) {
382 db.exec("PRAGMA foreign_keys = ON");
383 db.exec("PRAGMA journal_mode = WAL");
384 db.exec("PRAGMA synchronous = NORMAL");
385 }
386
387 fs::path schema_path(std::string_view filename) {
388 const fs::path compiled
389 = fs::path(__FILE__).parent_path().parent_path().parent_path()
390 / "schema" / filename;
391 const std::array candidates {
392 compiled,
393 fs::current_path() / "schema" / filename,
394 fs::current_path() / "arachne" / "schema" / filename,
395 };
396 for (const auto& path : candidates) {
397 std::error_code error;
398 if (fs::is_regular_file(path, error)) {
399 return path;
400 }
401 }
402 fail("cannot locate schema file " + std::string(filename));
403 }
404
405 void create_candidate_schema(const database& db) {
406 db.exec(read_bytes(schema_path("candidate_v1.sql")));
407 }
408
409 std::string domain_name(graph_domain) { return "candidate"; }
410
411 fs::path domain_path(const fs::path& root, graph_domain domain) {
412 return root / domain_name(domain);
413 }
414
415 std::string read_active_id(const fs::path& root, graph_domain domain) {
416 const fs::path pointer = domain_path(root, domain) / "ACTIVE";
417 std::error_code error;
418 if (!fs::exists(pointer, error)) {
419 return {};
420 }
421 std::string id = read_bytes(pointer);
422 while (!id.empty()
423 && std::isspace(static_cast<unsigned char>(id.back()))) {
424 id.pop_back();
425 }
426 if (id.empty() || !std::ranges::all_of(id, [](unsigned char c) {
427 return std::isalnum(c) != 0 || c == '-' || c == '_';
428 })) {
429 fail("invalid ACTIVE pointer content: " + pointer.string());
430 }
431 return id;
432 }
433
435 const fs::path& root, graph_domain domain, std::string_view id
436 ) {
437 const fs::path parent = domain_path(root, domain);
438 const fs::path pointer = parent / "ACTIVE";
439 const fs::path temporary
440 = parent / (".ACTIVE-" + crypto::sha256(id).substr(0, 16) + ".tmp");
441 write_bytes(temporary, std::string(id) + "\n");
442 if (std::rename(temporary.c_str(), pointer.c_str()) != 0) {
443 std::error_code ignored;
444 fs::remove(temporary, ignored);
445 fail(
446 "cannot atomically replace ACTIVE pointer: " + pointer.string()
447 );
448 }
449 }
450
452 const fs::path& root, graph_domain domain, std::string_view seed
453 ) {
454 const fs::path staging = domain_path(root, domain) / ".staging";
455 fs::create_directories(staging);
456 const std::string base = "stage-" + crypto::sha256(seed).substr(0, 20);
457 for (std::size_t suffix = 0; suffix < 1000; ++suffix) {
458 fs::path result = staging / base;
459 if (suffix != 0) {
460 result += "-" + std::to_string(suffix);
461 }
462 std::error_code error;
463 if (fs::create_directory(result, error)) {
464 return result;
465 }
466 if (error) {
467 fail("cannot create staging directory: " + error.message());
468 }
469 }
470 fail("cannot allocate a unique staging directory");
471 }
472
474 const fs::path& database_path,
475 const fs::path& expected_staging_directory
476 ) {
477 if (database_path.filename() != "graph.sqlite"
478 || database_path.parent_path() != expected_staging_directory) {
479 fail("refusing to clean SQLite sidecars outside expected staging");
480 }
481
482 const std::string directory_name
483 = expected_staging_directory.filename().string();
484 const bool snapshot_staging
485 = expected_staging_directory.parent_path().filename() == ".staging"
486 && directory_name.starts_with("stage-");
487 std::error_code error;
488 const fs::file_status staging_status
489 = fs::symlink_status(expected_staging_directory, error);
490 if (!snapshot_staging || error || !fs::is_directory(staging_status)) {
491 fail("refusing to clean SQLite sidecars outside safe staging");
492 }
493
494 for (const std::string_view suffix : { "-wal", "-shm", "-journal" }) {
495 fs::path sidecar = database_path;
496 sidecar += suffix;
497 error.clear();
498 const fs::file_status status = fs::symlink_status(sidecar, error);
499 if (error == std::errc::no_such_file_or_directory
500 || (!error && status.type() == fs::file_type::not_found)) {
501 continue;
502 }
503 if (error || !fs::is_regular_file(status)) {
504 fail(
505 "refusing to remove unsafe staging sidecar: "
506 + sidecar.string()
507 );
508 }
509 if (!fs::remove(sidecar, error) || error) {
510 fail(
511 "cannot remove checkpointed staging sidecar: "
512 + sidecar.string() + ": " + error.message()
513 );
514 }
515 }
516 }
517
518} // namespace
519} // namespace arachne::penelope
520
521namespace arachne::penelope {
522namespace {
523
526 ) {
527 if (value.empty() || value.size() > 128
528 || !std::ranges::all_of(value, [](unsigned char c) {
529 return std::isalnum(c) != 0 || c == '_' || c == '-'
530 || c == '.' || c == ':';
531 })) {
532 fail(std::string(context) + " must be a safe, non-empty stable ID");
533 }
534 }
535
537 const json& object, std::string_view key, std::string_view context
538 ) {
539 const auto value = object.find(std::string(key));
540 if (value == object.end() || !value->is_number_integer()) {
541 fail(
542 std::string(context) + "." + std::string(key)
543 + " must be an integer"
544 );
545 }
546 try {
547 return value->get<int>();
548 } catch (const json::exception&) {
549 fail(
550 std::string(context) + "." + std::string(key)
551 + " is outside the supported integer range"
552 );
553 }
554 }
555
557 const json& object, std::string_view key, std::string_view context
558 ) {
559 const auto value = object.find(std::string(key));
560 if (value == object.end() || !value->is_number()) {
561 fail(
562 std::string(context) + "." + std::string(key)
563 + " must be a number"
564 );
565 }
566 const double result = value->get<double>();
567 if (!std::isfinite(result)) {
568 fail(
569 std::string(context) + "." + std::string(key)
570 + " must be finite"
571 );
572 }
573 return result;
574 }
575
577 const json& object, std::initializer_list<std::string_view> fields,
578 std::string_view context
579 ) {
580 if (!object.is_object()) {
581 fail(std::string(context) + " must be an object");
582 }
583 const std::set<std::string_view> allowed(fields);
584 for (const auto& [key, value] : object.items()) {
585 (void)value;
586 if (!allowed.contains(key)) {
587 fail(
588 std::string(context) + " contains unsupported field " + key
589 );
590 }
591 }
592 }
593
595 sqlite3* db, const json& control, const json& payload
596 ) {
597 if (!payload.is_object()) {
598 fail("candidate materialization payload must be a JSON object");
599 }
600 require_only_fields(
601 payload,
602 { "artifact_type", "format_version", "plan_id", "source_snapshot",
603 "algorithm", "groups", "candidates", "works", "relations" },
604 "candidate payload"
605 );
606 if (payload.value("artifact_type", std::string {})
607 != "research_candidate_graph_materialization_v1"
608 || payload.value("format_version", 0) != 1) {
609 fail(
610 "candidate payload must identify "
611 "research_candidate_graph_materialization_v1"
612 );
613 }
614 const std::string payload_plan_id
615 = require_string(payload, "plan_id", "candidate payload");
617 payload_plan_id, "candidate payload.plan_id"
618 );
619 if (payload_plan_id
620 != require_string(control, "plan_id", "candidate control")) {
621 fail(
622 "candidate payload plan_id does not match its control contract"
623 );
624 }
625 for (const std::string_view key :
626 { "groups", "candidates", "works", "relations" }) {
627 const auto it = payload.find(std::string(key));
628 if (it == payload.end() || !it->is_array()) {
629 fail(
630 "candidate payload." + std::string(key)
631 + " must be an array"
632 );
633 }
634 }
635 const auto& source = control.at("source_snapshot");
636 const std::string source_id
637 = require_string(source, "snapshot_id", "control.source_snapshot");
638 const std::string source_ref
639 = require_string(source, "storage_ref", "control.source_snapshot");
640 const std::string source_hash = lowercase(
641 require_string(source, "sha256", "control.source_snapshot")
642 );
643 const auto& payload_source = payload.at("source_snapshot");
644 require_only_fields(
645 payload_source, { "snapshot_id", "storage_ref", "sha256" },
646 "payload.source_snapshot"
647 );
648 if (require_string(
649 payload_source, "snapshot_id", "payload.source_snapshot"
650 ) != source_id
651 || require_string(
652 payload_source, "storage_ref", "payload.source_snapshot"
653 ) != source_ref
654 || lowercase(require_string(
655 payload_source, "sha256", "payload.source_snapshot"
656 ))
657 != source_hash) {
658 fail(
659 "candidate payload source snapshot does not match its control "
660 "contract"
661 );
662 }
663 const auto& product = control.at("product_snapshot");
664 const std::string product_id = require_string(
665 product, "snapshot_id", "control.product_snapshot"
666 );
667 const std::string product_hash = lowercase(
668 require_string(product, "sha256", "control.product_snapshot")
669 );
670 const std::string algorithm
671 = require_string(control, "algorithm_version", "control");
672 const auto& configuration = control.at("configuration");
673 const std::string configuration_hash = lowercase(
674 require_string(configuration, "sha256", "control.configuration")
675 );
676 const json configuration_values = configuration.at("values");
677 if (crypto::sha256(canonical_json(configuration_values))
678 != configuration_hash) {
679 fail(
680 "candidate control configuration hash does not bind its "
681 "configuration values"
682 );
683 }
684 const auto& payload_algorithm = payload.at("algorithm");
685 require_only_fields(
686 payload_algorithm, { "name", "version", "configuration_sha256" },
687 "payload.algorithm"
688 );
689 const std::string payload_algorithm_name
690 = require_string(payload_algorithm, "name", "payload.algorithm");
691 const std::string payload_algorithm_version
692 = require_string(payload_algorithm, "version", "payload.algorithm");
693 if (algorithm
694 != payload_algorithm_name + "-" + payload_algorithm_version) {
695 fail(
696 "candidate payload algorithm identity/version differs from "
697 "its control"
698 );
699 }
700 if (lowercase(require_string(
701 payload_algorithm, "configuration_sha256", "payload.algorithm"
702 ))
703 != configuration_hash) {
704 fail(
705 "candidate payload configuration hash differs from its control"
706 );
707 }
708 statement graph_info(
709 db,
710 "INSERT INTO candidate_graph_info"
711 "(singleton,plan_version,source_snapshot_id,source_storage_ref,"
712 "source_snapshot_sha256,product_snapshot_id,product_snapshot_"
713 "sha256,"
714 "algorithm_version,configuration_sha256,configuration_json)"
715 " VALUES(1,1,?1,?2,?3,?4,?5,?6,?7,?8)"
716 );
717 graph_info.text(1, source_id);
718 graph_info.text(2, source_ref);
719 graph_info.text(3, source_hash);
720 graph_info.text(4, product_id);
721 graph_info.text(5, product_hash);
722 graph_info.text(6, algorithm);
723 graph_info.text(7, configuration_hash);
724 graph_info.text(8, canonical_json(configuration_values));
725 graph_info.done();
726
727 std::unordered_set<std::string> groups;
728 std::vector<json> ordered_groups;
729 for (const auto& group : array_or_empty(payload, "groups", "payload")) {
730 if (!group.is_object()) {
731 fail("payload.groups entries must be objects");
732 }
733 ordered_groups.push_back(group);
734 }
735 std::ranges::sort(ordered_groups, [](const json& lhs, const json& rhs) {
736 return lhs.value("group_id", std::string {})
737 < rhs.value("group_id", std::string {});
738 });
739 std::unordered_map<std::string, int> expected_group_counts;
740 for (const auto& group : ordered_groups) {
741 const std::string where
742 = "groups[" + std::to_string(groups.size()) + "]";
743 require_only_fields(
744 group,
745 { "group_id", "label", "order", "candidate_count", "rationale",
746 "attributes" },
747 where
748 );
749 const std::string id = require_string(group, "group_id", where);
750 validate_stable_contract_id(id, where + ".group_id");
751 if (!groups.emplace(id).second) {
752 fail(where + " duplicates group ID " + id);
753 }
754 const int candidate_count
755 = require_integer(group, "candidate_count", where);
756 if (candidate_count < 0) {
757 fail(where + ".candidate_count must be non-negative");
758 }
759 expected_group_counts.emplace(id, candidate_count);
760 json metadata { { "candidate_count", candidate_count },
761 { "rationale",
762 require_string(group, "rationale", where) } };
763 if (const auto attributes = group.find("attributes");
764 attributes != group.end()) {
765 if (!attributes->is_object()) {
766 fail(where + ".attributes must be an object");
767 }
768 metadata["attributes"] = *attributes;
769 }
770 statement insert(
771 db,
772 "INSERT INTO candidate_groups(id,label,ordinal,metadata_json)"
773 " VALUES(?1,?2,?3,?4)"
774 );
775 insert.text(1, id);
776 insert.text(2, require_string(group, "label", where));
777 insert.integer(3, require_integer(group, "order", where));
778 insert.text(4, canonical_json(metadata));
779 insert.done();
780 }
781
782 std::unordered_set<std::string> nodes;
783 std::unordered_set<std::string> candidates;
784 std::unordered_map<std::string, std::string> candidate_groups;
785 std::unordered_map<std::string, int> actual_group_counts;
786 std::size_t node_index = 0;
787 for (const auto& node :
788 array_or_empty(payload, "candidates", "payload")) {
789 const std::string where
790 = "candidates[" + std::to_string(node_index++) + "]";
791 if (!node.is_object()) {
792 fail(where + " must be an object");
793 }
794 require_only_fields(
795 node,
796 { "candidate_id", "external_id", "label", "kind", "rank",
797 "coverage", "group_id", "selection_reasons",
798 "source_snapshot_id", "attributes" },
799 where
800 );
801 const std::string id = require_string(node, "candidate_id", where);
802 validate_stable_contract_id(id, where + ".candidate_id");
803 if (!nodes.emplace(id).second) {
804 fail(where + " duplicates candidate node ID " + id);
805 }
806 candidates.emplace(id);
807 const std::string group_id
808 = require_string(node, "group_id", where);
809 if (!groups.contains(group_id)) {
810 fail(where + ".group_id references an unknown group");
811 }
812 candidate_groups.emplace(id, group_id);
813 ++actual_group_counts[group_id];
814 const std::string kind = require_string(node, "kind", where);
815 if (kind != "candidate" && kind != "grey") {
816 fail(where + ".kind must be candidate or grey");
817 }
818 const auto& reasons = node.at("selection_reasons");
819 if (!reasons.is_array() || reasons.empty()) {
820 fail(where + ".selection_reasons must be a non-empty array");
821 }
822 for (const auto& reason : reasons) {
823 if (!reason.is_string()
824 || reason.get_ref<const std::string&>().empty()) {
825 fail(
826 where
827 + ".selection_reasons must contain non-empty strings"
828 );
829 }
830 }
831 if (require_string(node, "source_snapshot_id", where)
832 != source_id) {
833 fail(where + " references the wrong source snapshot");
834 }
835 json source_metadata {
836 { "source_snapshot_id",
837 require_string(node, "source_snapshot_id", where) },
838 { "external_id", require_string(node, "external_id", where) }
839 };
840 if (const auto attributes = node.find("attributes");
841 attributes != node.end()) {
842 if (!attributes->is_object()) {
843 fail(where + ".attributes must be an object");
844 }
845 source_metadata["attributes"] = *attributes;
846 }
847 statement insert(
848 db,
849 "INSERT INTO candidate_nodes"
850 "(id,entity_ref,entity_type,label,rank,coverage,group_id,is_"
851 "grey,"
852 "selection_reason_json,source_metadata_json)"
853 " VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)"
854 );
855 insert.text(1, id);
856 insert.text(2, require_string(node, "external_id", where));
857 insert.text(3, kind);
858 insert.text(4, require_string(node, "label", where));
859 insert.integer(5, require_integer(node, "rank", where));
860 insert.real(6, require_number(node, "coverage", where));
861 insert.text(7, group_id);
862 insert.integer(8, kind == "grey" ? 1 : 0);
863 insert.text(9, canonical_json(reasons));
864 insert.text(10, canonical_json(source_metadata));
865 insert.done();
866 }
867 for (const auto& [group, expected] : expected_group_counts) {
868 if (actual_group_counts[group] != expected) {
869 fail(
870 "group candidate_count does not match candidate "
871 "membership: "
872 + group
873 );
874 }
875 }
876
877 std::size_t work_index = 0;
878 for (const auto& work : array_or_empty(payload, "works", "payload")) {
879 const std::string where
880 = "works[" + std::to_string(work_index++) + "]";
881 if (!work.is_object()) {
882 fail(where + " must be an object");
883 }
884 require_only_fields(
885 work,
886 { "work_id", "candidate_id", "external_id", "label", "year",
887 "source_snapshot_id", "attributes" },
888 where
889 );
890 const std::string id = require_string(work, "work_id", where);
891 validate_stable_contract_id(id, where + ".work_id");
892 if (!nodes.emplace(id).second) {
893 fail(where + " duplicates a candidate/work node ID " + id);
894 }
895 const std::string candidate_id
896 = require_string(work, "candidate_id", where);
897 if (!candidates.contains(candidate_id)) {
898 fail(where + ".candidate_id references an unknown candidate");
899 }
900 if (require_string(work, "source_snapshot_id", where)
901 != source_id) {
902 fail(where + " references the wrong source snapshot");
903 }
904 json metadata { { "candidate_id", candidate_id },
905 { "source_snapshot_id", source_id } };
906 if (const auto year = optional_integer(work, "year", where)) {
907 metadata["year"] = *year;
908 }
909 if (const auto attributes = work.find("attributes");
910 attributes != work.end()) {
911 if (!attributes->is_object()) {
912 fail(where + ".attributes must be an object");
913 }
914 metadata["attributes"] = *attributes;
915 }
916 statement insert(
917 db,
918 "INSERT INTO candidate_nodes"
919 "(id,entity_ref,entity_type,label,rank,coverage,group_id,is_"
920 "grey,"
921 "selection_reason_json,source_metadata_json)"
922 " VALUES(?1,?2,'candidate_work',?3,NULL,0,?4,0,'[]',?5)"
923 );
924 insert.text(1, id);
925 insert.text(2, require_string(work, "external_id", where));
926 insert.text(3, require_string(work, "label", where));
927 insert.text(4, candidate_groups.at(candidate_id));
928 insert.text(5, canonical_json(metadata));
929 insert.done();
930 }
931
932 std::unordered_set<std::string> edges;
933 std::size_t edge_index = 0;
934 for (const auto& edge :
935 array_or_empty(payload, "relations", "payload")) {
936 const std::string where
937 = "relations[" + std::to_string(edge_index++) + "]";
938 if (!edge.is_object()) {
939 fail(where + " must be an object");
940 }
941 require_only_fields(
942 edge,
943 { "relation_id", "source_id", "target_id", "relation_type",
944 "weight", "provenance", "attributes" },
945 where
946 );
947 const std::string id = require_string(edge, "relation_id", where);
948 validate_stable_contract_id(id, where + ".relation_id");
949 if (!edges.emplace(id).second) {
950 fail(where + " duplicates candidate edge ID " + id);
951 }
952 const std::string subject
953 = require_string(edge, "source_id", where);
954 const std::string object = require_string(edge, "target_id", where);
955 if (!nodes.contains(subject) || !nodes.contains(object)) {
956 fail(where + " references an unknown candidate node");
957 }
958 const auto& provenance = edge.at("provenance");
959 require_only_fields(
960 provenance,
961 { "origin", "source_snapshot_id", "algorithm_version",
962 "explanation" },
963 where + ".provenance"
964 );
965 if (require_string(provenance, "origin", where + ".provenance")
966 != "algorithmic_external"
967 || require_string(
968 provenance, "source_snapshot_id", where + ".provenance"
969 ) != source_id
970 || require_string(
971 provenance, "algorithm_version", where + ".provenance"
972 ) != payload_algorithm_version) {
973 fail(where + ".provenance is inconsistent with payload inputs");
974 }
975 (void)require_string(
976 provenance, "explanation", where + ".provenance"
977 );
978 json edge_metadata = provenance;
979 if (const auto attributes = edge.find("attributes");
980 attributes != edge.end()) {
981 if (!attributes->is_object()) {
982 fail(where + ".attributes must be an object");
983 }
984 edge_metadata["attributes"] = *attributes;
985 }
986 statement insert(
987 db,
988 "INSERT INTO candidate_edges"
989 "(id,subject_id,relation_type,object_id,weight,metadata_json)"
990 " VALUES(?1,?2,?3,?4,?5,?6)"
991 );
992 insert.text(1, id);
993 insert.text(2, subject);
994 insert.text(3, require_string(edge, "relation_type", where));
995 insert.text(4, object);
996 insert.optional_real(5, optional_number(edge, "weight", where));
997 insert.text(6, canonical_json(edge_metadata));
998 insert.done();
999 }
1000
1001 const auto& summary = control.at("summary");
1002 if (require_integer(summary, "candidate_count", "control.summary")
1003 != static_cast<int>(
1004 array_or_empty(payload, "candidates", "payload").size()
1005 )
1006 || require_integer(summary, "edge_count", "control.summary")
1007 != static_cast<int>(
1008 array_or_empty(payload, "relations", "payload").size()
1009 )
1010 || require_integer(summary, "group_count", "control.summary")
1011 != static_cast<int>(ordered_groups.size())) {
1012 fail(
1013 "candidate control summary does not match the resolved payload"
1014 );
1015 }
1016 }
1017
1018} // namespace
1019} // namespace arachne::penelope
1020
1021namespace arachne::penelope {
1022namespace {
1023
1024 bool query_has_row(sqlite3* db, std::string_view sql) {
1025 statement query(db, sql);
1026 return query.row();
1027 }
1028
1030 integrity_report& report, sqlite3* db, std::string_view sql,
1031 std::string message
1032 ) {
1033 if (query_has_row(db, sql)) {
1034 report.problems.push_back(std::move(message));
1035 }
1036 }
1037
1038 integrity_report inspect_database(
1039 graph_domain domain, const fs::path& database_path,
1041 ) {
1042 static_cast<void>(domain);
1043 integrity_report report;
1044 if (!fs::is_regular_file(database_path)) {
1045 report.problems.push_back("database file does not exist");
1046 return report;
1047 }
1048 try {
1049 database db(database_path, SQLITE_OPEN_READONLY, access);
1050 db.exec("PRAGMA foreign_keys = ON");
1051 {
1052 statement check(db.get(), "PRAGMA integrity_check");
1053 bool saw_result = false;
1054 while (check.row()) {
1055 saw_result = true;
1056 const auto* raw = sqlite3_column_text(check.get(), 0);
1057 const std::string value = raw == nullptr
1058 ? std::string {}
1059 : reinterpret_cast<const char*>(raw);
1060 if (value != "ok") {
1061 report.problems.push_back("SQLite integrity: " + value);
1062 }
1063 }
1064 if (!saw_result) {
1065 report.problems.push_back(
1066 "SQLite integrity_check returned no result"
1067 );
1068 }
1069 }
1070 {
1071 statement foreign_keys(db.get(), "PRAGMA foreign_key_check");
1072 while (foreign_keys.row()) {
1073 const auto* table
1074 = sqlite3_column_text(foreign_keys.get(), 0);
1075 report.problems.push_back(
1076 "foreign-key violation in table "
1077 + std::string(
1078 table == nullptr
1079 ? "unknown"
1080 : reinterpret_cast<const char*>(table)
1081 )
1082 );
1083 }
1084 }
1085
1086 add_problem_if(
1087 report, db.get(),
1088 "SELECT 1 WHERE (SELECT count(*) FROM "
1089 "candidate_graph_info)<>1",
1090 "candidate database must contain exactly one graph-info row"
1091 );
1092 add_problem_if(
1093 report, db.get(),
1094 "SELECT 1 FROM candidate_edges WHERE subject_id=object_id "
1095 "LIMIT 1",
1096 "candidate graph contains a self edge"
1097 );
1098 } catch (const std::exception& error) {
1099 report.problems.push_back(error.what());
1100 }
1101 report.ok = report.problems.empty();
1102 return report;
1103 }
1104
1106 const graph_domain domain, const fs::path& database_path
1107 ) {
1108 {
1109 database db(database_path, SQLITE_OPEN_READWRITE);
1111 db.exec("PRAGMA optimize");
1112 {
1113 statement checkpoint(
1114 db.get(), "PRAGMA wal_checkpoint(TRUNCATE)"
1115 );
1116 if (!checkpoint.row()
1117 || sqlite3_column_int(checkpoint.get(), 0) != 0) {
1118 fail("SQLite WAL checkpoint did not complete");
1119 }
1120 }
1121 statement required_mode(db.get(), "PRAGMA journal_mode");
1122 if (!required_mode.row()) {
1123 fail("cannot inspect checkpointed database journal mode");
1124 }
1125 const auto* mode = sqlite3_column_text(required_mode.get(), 0);
1126 const std::string actual_mode = mode == nullptr
1127 ? std::string {}
1128 : lowercase(reinterpret_cast<const char*>(mode));
1129 if (actual_mode != "wal") {
1130 fail(
1131 "checkpointed database did not retain required WAL mode: "
1132 + actual_mode
1133 );
1134 }
1135 }
1136 const auto report = inspect_database(
1137 domain, database_path, database_access::immutable_readonly
1138 );
1139 if (!report.ok) {
1140 std::string message = "database integrity validation failed";
1141 for (const auto& problem : report.problems) {
1142 message += "\n- " + problem;
1143 }
1144 fail(std::move(message));
1145 }
1146 }
1147
1148 struct export_table final {
1151 };
1152
1154 static const std::vector<export_table> candidate {
1155 { "candidate_graph_info", "singleton" },
1156 { "candidate_groups", "id" },
1157 { "candidate_nodes", "id" },
1158 { "candidate_edges", "id" },
1159 };
1160 static_cast<void>(domain);
1161 return candidate;
1162 }
1163
1164 json sqlite_value(sqlite3_stmt* statement_value, int column) {
1165 switch (sqlite3_column_type(statement_value, column)) {
1166 case SQLITE_NULL:
1167 return nullptr;
1168 case SQLITE_INTEGER:
1169 return sqlite3_column_int64(statement_value, column);
1170 case SQLITE_FLOAT: {
1171 const double value = sqlite3_column_double(statement_value, column);
1172 if (!std::isfinite(value)) {
1173 fail("database contains a non-finite number");
1174 }
1175 return value;
1176 }
1177 case SQLITE_TEXT: {
1178 const auto* value = sqlite3_column_text(statement_value, column);
1179 const int bytes = sqlite3_column_bytes(statement_value, column);
1180 return std::string(
1181 reinterpret_cast<const char*>(value),
1182 static_cast<std::size_t>(bytes)
1183 );
1184 }
1185 case SQLITE_BLOB:
1186 fail("canonical graph tables must not contain BLOB values");
1187 default:
1188 fail("unknown SQLite column type");
1189 }
1190 }
1191
1193 const fs::path& root, graph_domain domain, std::string id,
1194 bool verify_hashes
1195 ) {
1196 const fs::path directory = domain_path(root, domain) / "snapshots" / id;
1197 snapshot_result result;
1198 result.domain = domain;
1199 result.snapshot_id = std::move(id);
1200 result.database_path = directory / "graph.sqlite";
1201 result.export_path = directory / "graph.jsonl";
1202 result.metadata_path = directory / "metadata.json";
1203 const json metadata = read_json(result.metadata_path);
1204 const auto validation = arachnespace::contracts::validate(
1207 metadata
1208 );
1209 if (!validation.valid()
1210 || metadata.value("contract", std::string {})
1211 != candidate_contract
1212 || metadata.value("snapshot_id", std::string {})
1213 != result.snapshot_id) {
1214 fail("snapshot metadata does not match ACTIVE pointer");
1215 }
1216 result.database_sha256 = lowercase(require_string(
1217 metadata.at("database"), "sha256", "metadata.database"
1218 ));
1219 const auto& exports = metadata.at("exports");
1220 if (exports.empty() || !exports.front().is_object()
1221 || !exports.front().contains("artifact")) {
1222 fail("snapshot metadata has no deterministic export artifact");
1223 }
1224 result.export_sha256 = lowercase(require_string(
1225 exports.front().at("artifact"), "sha256",
1226 "metadata.exports[0].artifact"
1227 ));
1228 require_sha256(result.database_sha256, "metadata.database_sha256");
1229 require_sha256(result.export_sha256, "metadata.export_sha256");
1230 if (verify_hashes
1231 && (crypto::sha256_file(result.database_path)
1232 != result.database_sha256
1233 || crypto::sha256_file(result.export_path)
1234 != result.export_sha256)) {
1235 fail("active snapshot content hash does not match metadata");
1236 }
1237 return result;
1238 }
1239
1240 snapshot_result finalize_snapshot(
1241 const fs::path& root, graph_domain domain, staging_guard& staging,
1242 std::string snapshot_id, std::string database_hash,
1243 std::string export_hash, std::size_t applied, std::size_t skipped
1244 ) {
1245 const fs::path final_directory
1246 = domain_path(root, domain) / "snapshots" / snapshot_id;
1247 if (fs::exists(final_directory)) {
1248 const snapshot_result existing
1249 = snapshot_from_directory(root, domain, snapshot_id, true);
1250 if (existing.database_sha256 != database_hash
1251 || existing.export_sha256 != export_hash) {
1252 fail("snapshot ID collision with different content");
1253 }
1254 } else {
1255 std::error_code error;
1256 fs::rename(staging.path, final_directory, error);
1257 if (error) {
1258 fail(
1259 "cannot publish immutable snapshot directory: "
1260 + error.message()
1261 );
1262 }
1263 staging.keep = true;
1264 }
1265 const std::string previous = read_active_id(root, domain);
1266 const bool changed = previous != snapshot_id;
1267 if (changed) {
1268 write_active_id(root, domain, snapshot_id);
1269 }
1270 snapshot_result result = snapshot_from_directory(
1271 root, domain, std::move(snapshot_id), true
1272 );
1273 result.applied_inputs = applied;
1274 result.skipped_inputs = skipped;
1275 result.activated = changed;
1276 result.changed = changed;
1277 return result;
1278 }
1279
1280
1281} // namespace
1282
1283store::store(fs::path root) {
1284 if (root.empty()) {
1285 fail("Penelope store root must not be empty");
1286 }
1287 root_ = fs::absolute(std::move(root)).lexically_normal();
1288 if (root_ == root_.root_path()) {
1289 fail("Penelope store root must not be a filesystem root");
1290 }
1291 for (const auto& component : root_) {
1292 if (component == "inbox") {
1293 fail(
1294 "Penelope store root must never be inside the immutable inbox"
1295 );
1296 }
1297 }
1298 fs::create_directories(
1299 domain_path(root_, graph_domain::candidate) / "snapshots"
1300 );
1301 fs::create_directories(
1302 domain_path(root_, graph_domain::candidate) / ".staging"
1303 );
1304}
1305
1306std::optional<snapshot_result>
1307store::active_snapshot(const graph_domain domain) const {
1308 const std::string id = read_active_id(root_, domain);
1309 if (id.empty()) {
1310 return std::nullopt;
1311 }
1312 return snapshot_from_directory(root_, domain, id, true);
1313}
1314
1315integrity_report store::integrity_check(
1316 const graph_domain domain, const fs::path& database_path
1317) const {
1318 return inspect_database(
1319 domain, database_path, database_access::immutable_readonly
1320 );
1321}
1322
1324 const graph_domain domain, const fs::path& database_path
1325) const {
1326 if (const auto active = active_snapshot(domain)) {
1327 std::error_code error;
1328 if (fs::equivalent(active->database_path, database_path, error)
1329 && !error) {
1330 fail("refusing to checkpoint an active immutable snapshot");
1331 }
1332 }
1333 seal_and_validate_database(domain, database_path);
1334 remove_staging_sqlite_sidecars(database_path, database_path.parent_path());
1335}
1336
1337std::string store::export_jsonl(
1338 const graph_domain domain, const fs::path& database_path,
1339 const fs::path& destination
1340) const {
1341 database db(
1342 database_path, SQLITE_OPEN_READONLY, database_access::immutable_readonly
1343 );
1344 db.exec("PRAGMA foreign_keys = ON");
1345 if (!destination.parent_path().empty()) {
1346 fs::create_directories(destination.parent_path());
1347 }
1348 std::ofstream output(destination, std::ios::binary | std::ios::trunc);
1349 if (!output) {
1350 fail("cannot create deterministic export: " + destination.string());
1351 }
1352 for (const auto& table : export_tables(domain)) {
1353 statement rows(
1354 db.get(),
1355 "SELECT * FROM " + std::string(table.name) + " ORDER BY "
1356 + std::string(table.order)
1357 );
1358 while (rows.row()) {
1359 json values = json::object();
1360 const int columns = sqlite3_column_count(rows.get());
1361 for (int column = 0; column < columns; ++column) {
1362 values[sqlite3_column_name(rows.get(), column)]
1363 = sqlite_value(rows.get(), column);
1364 }
1365 json line { { "table", table.name }, { "row", std::move(values) } };
1366 output << canonical_json(line) << '\n';
1367 if (!output) {
1368 fail("failed while writing deterministic export");
1369 }
1370 }
1371 }
1372 output.flush();
1373 if (!output) {
1374 fail("failed while flushing deterministic export");
1375 }
1376 output.close();
1377 return crypto::sha256_file(destination);
1378}
1379
1380namespace {
1381
1383 arachnespace::contracts::contract_name expected, const json& document,
1384 std::string_view context
1385 ) {
1386 const auto validation
1387 = arachnespace::contracts::validate(expected, document);
1388 if (validation.valid()) {
1389 return;
1390 }
1391 std::string message
1392 = std::string(context) + " contract validation failed";
1393 for (const auto& diagnostic : validation.diagnostics) {
1394 message += "\n- " + diagnostic.instance_path + " ["
1395 + diagnostic.code + "] " + diagnostic.message;
1396 }
1397 fail(std::move(message));
1398 }
1399
1400 json read_candidate_payload(const fs::path& path) {
1401 const std::string bytes = read_bytes(path);
1402 try {
1403 return json::parse(bytes);
1404 } catch (const json::parse_error&) {
1405 json result {
1406 { "groups", json::array() },
1407 { "candidates", json::array() },
1408 { "works", json::array() },
1409 { "relations", json::array() },
1410 };
1411 std::istringstream lines(bytes);
1412 std::string line;
1413 std::size_t line_number = 0;
1414 while (std::getline(lines, line)) {
1415 ++line_number;
1416 if (line.empty()) {
1417 continue;
1418 }
1419 json record;
1420 try {
1421 record = json::parse(line);
1422 } catch (const json::exception& error) {
1423 fail(
1424 "invalid candidate JSONL line "
1425 + std::to_string(line_number) + ": " + error.what()
1426 );
1427 }
1428 if (!record.is_object()) {
1429 fail("candidate JSONL records must be objects");
1430 }
1431 const std::string kind
1432 = record.value("record_type", std::string {});
1433 if (kind.empty() || kind == "header") {
1434 const json data
1435 = record.contains("data") ? record["data"] : record;
1436 if (!data.is_object()) {
1437 fail("candidate JSONL header data must be an object");
1438 }
1439 for (const auto& [key, value] : data.items()) {
1440 if (key != "record_type" && key != "data") {
1441 result[key] = value;
1442 }
1443 }
1444 continue;
1445 }
1446 static const std::map<std::string, std::string, std::less<>>
1447 arrays {
1448 { "group", "groups" },
1449 { "candidate", "candidates" },
1450 { "work", "works" },
1451 { "relation", "relations" },
1452 };
1453 const auto destination = arrays.find(kind);
1454 if (destination == arrays.end()) {
1455 fail("unknown candidate JSONL record_type: " + kind);
1456 }
1457 json data = record.contains("data") ? record["data"] : record;
1458 if (!data.is_object()) {
1459 fail("candidate JSONL record data must be an object");
1460 }
1461 data.erase("record_type");
1462 result[destination->second].push_back(std::move(data));
1463 }
1464 return result;
1465 }
1466 }
1467
1468 std::string utc_now() {
1469 const std::time_t now = std::time(nullptr);
1470 std::tm broken_down {};
1471 if (gmtime_r(&now, &broken_down) == nullptr) {
1472 fail("cannot create snapshot activation timestamp");
1473 }
1474 std::array<char, 32> buffer {};
1475 if (std::strftime(
1476 buffer.data(), buffer.size(), "%Y-%m-%dT%H:%M:%SZ", &broken_down
1477 )
1478 == 0) {
1479 fail("cannot format snapshot activation timestamp");
1480 }
1481 return buffer.data();
1482 }
1483
1485 std::string storage_ref, std::string sha256, std::uintmax_t byte_length,
1486 std::string media_type
1487 ) {
1488 return {
1489 { "storage_ref", std::move(storage_ref) },
1490 { "sha256", std::move(sha256) },
1491 { "byte_length", byte_length },
1492 { "media_type", std::move(media_type) },
1493 };
1494 }
1495
1497 const fs::path& staging_path, graph_domain domain,
1498 std::string_view snapshot_id
1499 ) {
1500 const json report {
1501 { "format_version", 1 },
1502 { "domain", domain_name(domain) },
1503 { "snapshot_id", snapshot_id },
1504 { "passed", true },
1505 { "checks",
1506 { { "sqlite_integrity", "ok" },
1507 { "foreign_keys", "ok" },
1508 { "domain_structure", "ok" } } },
1509 };
1510 const fs::path path = staging_path / "validation.json";
1511 write_bytes(path, canonical_json(report) + "\n");
1512 return artifact(
1513 domain_name(domain) + "/snapshots/" + std::string(snapshot_id)
1514 + "/validation.json",
1515 crypto::sha256_file(path), fs::file_size(path), "application/json"
1516 );
1517 }
1518
1519} // namespace
1520
1521
1522snapshot_result
1523store::replace_candidate_snapshot(const candidate_snapshot_request& request) {
1524 if (request.run_id.empty()) {
1525 fail("candidate snapshot request requires a run_id");
1526 }
1527 const auto& descriptor = request.plan;
1528 if (!fs::is_regular_file(descriptor.control_contract_path)
1529 || !fs::is_regular_file(descriptor.resolved_plan_payload_path)) {
1530 fail("candidate control contract and resolved payload must both exist");
1531 }
1532 const json control = read_json(descriptor.control_contract_path);
1533 require_valid_contract(
1535 control, "candidate plan"
1536 );
1537 const auto& plan_artifact = control.at("plan_artifact");
1538 const std::string payload_hash = lowercase(
1539 require_string(plan_artifact, "sha256", "control.plan_artifact")
1540 );
1541 require_sha256(payload_hash, "control.plan_artifact.sha256");
1542 if (crypto::sha256_file(descriptor.resolved_plan_payload_path)
1543 != payload_hash) {
1544 fail(
1545 "resolved candidate plan payload hash does not match control "
1546 "contract"
1547 );
1548 }
1549 const auto expected_bytes
1550 = plan_artifact.at("byte_length").get<std::uint64_t>();
1551 if (fs::file_size(descriptor.resolved_plan_payload_path)
1552 != expected_bytes) {
1553 fail(
1554 "resolved candidate plan payload length does not match control "
1555 "contract"
1556 );
1557 }
1558 const json payload
1559 = read_candidate_payload(descriptor.resolved_plan_payload_path);
1560 const std::string seed = request.run_id + "\n"
1561 + require_string(control, "plan_id", "candidate control") + "\n"
1562 + payload_hash;
1563 staging_guard staging {
1564 .path = make_staging_directory(root_, graph_domain::candidate, seed)
1565 };
1566 const fs::path database_path = staging.path / "graph.sqlite";
1567 {
1568 database db(database_path, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE);
1571 transaction build(db);
1572 import_candidate_plan(db.get(), control, payload);
1573 build.commit();
1574 }
1576 const fs::path export_path = staging.path / "graph.jsonl";
1577 const std::string export_hash
1578 = export_jsonl(graph_domain::candidate, database_path, export_path);
1579 const std::string database_hash = crypto::sha256_file(database_path);
1580 remove_staging_sqlite_sidecars(database_path, staging.path);
1581 const std::string snapshot_id = "candidate_" + export_hash.substr(0, 32);
1582 const json validation_artifact = write_validation_report(
1583 staging.path, graph_domain::candidate, snapshot_id
1584 );
1585 const std::string database_ref
1586 = "candidate/snapshots/" + snapshot_id + "/graph.sqlite";
1587 const std::string export_ref
1588 = "candidate/snapshots/" + snapshot_id + "/graph.jsonl";
1589 const json metadata {
1590 { "contract", candidate_contract },
1591 { "format_version", 1 },
1592 { "snapshot_id", snapshot_id },
1593 { "run_id", request.run_id },
1594 { "graph_version", "candidate-schema-v1" },
1595 { "content_sha256", database_hash },
1596 { "database",
1597 artifact(
1598 database_ref, database_hash, fs::file_size(database_path),
1599 "application/vnd.sqlite3"
1600 ) },
1601 { "exports",
1602 json::array(
1603 { { { "kind", "candidate-jsonl" },
1604 { "artifact",
1605 artifact(
1606 export_ref, export_hash, fs::file_size(export_path),
1607 "application/x-ndjson"
1608 ) } } }
1609 ) },
1610 { "plan_id", control.at("plan_id") },
1611 { "source_snapshot_id",
1612 control.at("source_snapshot").at("snapshot_id") },
1613 { "activated_at", utc_now() },
1614 { "structural_validation",
1615 { { "passed", true }, { "report", validation_artifact } } },
1616 { "extensions",
1617 { { "org.ninjaro.penelope",
1618 { { "control_contract_ref",
1619 descriptor.control_contract_path.generic_string() },
1620 { "control_contract_sha256",
1621 crypto::sha256_file(descriptor.control_contract_path) },
1622 { "plan_artifact", plan_artifact } } } } },
1623 };
1624 require_valid_contract(
1627 metadata, "candidate snapshot metadata"
1628 );
1629 write_bytes(
1630 staging.path / "metadata.json", canonical_json(metadata) + "\n"
1631 );
1632 return finalize_snapshot(
1633 root_, graph_domain::candidate, staging, snapshot_id, database_hash,
1634 export_hash, 1, 0
1635 );
1636}
1637
1638} // namespace arachne::penelope
database(const fs::path &path, int flags, const database_access access=database_access::ordinary)
Definition store.cpp:152
void integer(int index, sqlite3_int64 value)
Definition store.cpp:79
void optional_real(int index, const std::optional< double > &value)
Definition store.cpp:98
void text(int index, std::string_view value)
Definition store.cpp:69
snapshot_result replace_candidate_snapshot(const candidate_snapshot_request &request)
Definition store.cpp:1523
store(std::filesystem::path root)
Definition store.cpp:1283
std::optional< snapshot_result > active_snapshot(graph_domain domain) const
Definition store.cpp:1307
void checkpoint_staging(graph_domain domain, const std::filesystem::path &database_path) const
Definition store.cpp:1323
integrity_report integrity_check(graph_domain domain, const std::filesystem::path &database_path) const
Definition store.cpp:1315
std::string export_jsonl(graph_domain domain, const std::filesystem::path &database_path, const std::filesystem::path &destination) const
Definition store.cpp:1337
std::string sha256(std::span< const std::byte > bytes)
Definition crypto.cpp:209
std::string sha256_file(const std::filesystem::path &path)
Definition crypto.cpp:221
void require_sha256(std::string_view value, std::string_view field)
Definition store.cpp:294
std::optional< int > optional_integer(const json &object, std::string_view key, std::string_view context)
Definition store.cpp:325
void write_active_id(const fs::path &root, graph_domain domain, std::string_view id)
Definition store.cpp:434
void require_valid_contract(arachnespace::contracts::contract_name expected, const json &document, std::string_view context)
Definition store.cpp:1382
const std::vector< export_table > & export_tables(graph_domain domain)
Definition store.cpp:1153
void remove_staging_sqlite_sidecars(const fs::path &database_path, const fs::path &expected_staging_directory)
Definition store.cpp:473
void seal_and_validate_database(const graph_domain domain, const fs::path &database_path)
Definition store.cpp:1105
std::string canonical_json(const json &value)
Definition store.cpp:300
json artifact(std::string storage_ref, std::string sha256, std::uintmax_t byte_length, std::string media_type)
Definition store.cpp:1484
snapshot_result snapshot_from_directory(const fs::path &root, graph_domain domain, std::string id, bool verify_hashes)
Definition store.cpp:1192
std::string lowercase(std::string value)
Definition store.cpp:279
fs::path schema_path(std::string_view filename)
Definition store.cpp:387
std::optional< double > optional_number(const json &object, std::string_view key, std::string_view context)
Definition store.cpp:341
void validate_stable_contract_id(std::string_view value, std::string_view context)
Definition store.cpp:524
double require_number(const json &object, std::string_view key, std::string_view context)
Definition store.cpp:556
integrity_report inspect_database(graph_domain domain, const fs::path &database_path, const database_access access=database_access::ordinary)
Definition store.cpp:1038
std::string read_bytes(const fs::path &path)
Definition store.cpp:246
const json & array_or_empty(const json &object, std::string_view key, std::string_view context)
Definition store.cpp:364
void create_candidate_schema(const database &db)
Definition store.cpp:405
std::string sqlite_immutable_uri(const fs::path &path)
Definition store.cpp:128
void add_problem_if(integrity_report &report, sqlite3 *db, std::string_view sql, std::string message)
Definition store.cpp:1029
constexpr std::string_view candidate_contract
Definition store.cpp:40
json sqlite_value(sqlite3_stmt *statement_value, int column)
Definition store.cpp:1164
int require_integer(const json &object, std::string_view key, std::string_view context)
Definition store.cpp:536
bool is_sha256(std::string_view value)
Definition store.cpp:286
void import_candidate_plan(sqlite3 *db, const json &control, const json &payload)
Definition store.cpp:594
json write_validation_report(const fs::path &staging_path, graph_domain domain, std::string_view snapshot_id)
Definition store.cpp:1496
fs::path make_staging_directory(const fs::path &root, graph_domain domain, std::string_view seed)
Definition store.cpp:451
json read_candidate_payload(const fs::path &path)
Definition store.cpp:1400
void configure_connection(const database &db)
Definition store.cpp:381
fs::path domain_path(const fs::path &root, graph_domain domain)
Definition store.cpp:411
snapshot_result finalize_snapshot(const fs::path &root, graph_domain domain, staging_guard &staging, std::string snapshot_id, std::string database_hash, std::string export_hash, std::size_t applied, std::size_t skipped)
Definition store.cpp:1240
void write_bytes(const fs::path &path, std::string_view bytes)
Definition store.cpp:267
bool query_has_row(sqlite3 *db, std::string_view sql)
Definition store.cpp:1024
std::string read_active_id(const fs::path &root, graph_domain domain)
Definition store.cpp:415
void require_only_fields(const json &object, std::initializer_list< std::string_view > fields, std::string_view context)
Definition store.cpp:576
std::string sqlite_message(sqlite3 *db, std::string_view operation)
Definition store.cpp:46
std::string require_string(const json &object, std::string_view key, std::string_view context)
Definition store.cpp:311