24 using sqlite_handle = std::unique_ptr<sqlite3,
decltype(&sqlite3_close)>;
26 = std::unique_ptr<sqlite3_stmt,
decltype(&sqlite3_finalize)>;
28 std::atomic<std::uint64_t> intake_stage_sequence { 0 };
29 std::atomic<std::uint64_t> lock_token_sequence { 0 };
32 throw_sqlite(sqlite3* database, std::string_view context) {
33 throw std::runtime_error(
34 std::string(context) +
": " + sqlite3_errmsg(database)
38 void execute(sqlite3* database, std::string_view sql) {
39 char* error =
nullptr;
40 const std::string owned(sql);
41 if (sqlite3_exec(database, owned.c_str(),
nullptr,
nullptr, &error)
43 const std::string message
44 = error !=
nullptr ? error :
"SQLite error";
46 throw std::runtime_error(message);
51 sqlite3_stmt* raw =
nullptr;
52 const std::string owned(sql);
53 if (sqlite3_prepare_v2(database, owned.c_str(), -1, &raw,
nullptr)
55 throw_sqlite(database,
"prepare statement");
57 return { raw, sqlite3_finalize };
61 sqlite3* database, sqlite3_stmt* statement,
int index,
62 std::string_view value
68 const char*
const bytes = value.empty() ?
"" : value.data();
69 if (sqlite3_bind_text(
70 statement, index, bytes,
static_cast<
int>(value.size()),
74 throw_sqlite(database,
"bind text");
79 sqlite3* database, sqlite3_stmt* statement,
int index,
80 const std::optional<std::string>& value
82 if (value.has_value()) {
83 bind_text(database, statement, index, *value);
84 }
else if (sqlite3_bind_null(statement, index) != SQLITE_OK) {
85 throw_sqlite(database,
"bind null");
89 void step_done(sqlite3* database, sqlite3_stmt* statement) {
90 if (sqlite3_step(statement) != SQLITE_DONE) {
91 throw_sqlite(database,
"execute statement");
95 std::string column_text(sqlite3_stmt* statement,
int column) {
96 const auto* value = sqlite3_column_text(statement, column);
97 if (value ==
nullptr) {
100 return reinterpret_cast<
const char*>(value);
103 std::optional<std::string>
104 optional_column_text(sqlite3_stmt* statement,
int column) {
105 if (sqlite3_column_type(statement, column) == SQLITE_NULL) {
108 return column_text(statement, column);
111 std::string utc_timestamp() {
112 const auto now = std::chrono::system_clock::now();
113 const std::time_t timestamp = std::chrono::system_clock::to_time_t(now);
116 gmtime_s(&tm, ×tamp);
118 gmtime_r(×tamp, &tm);
120 std::ostringstream result;
121 result << std::put_time(&tm,
"%Y-%m-%dT%H:%M:%SZ");
125 std::int64_t unix_seconds() {
126 return std::chrono::duration_cast<std::chrono::seconds>(
127 std::chrono::system_clock::now().time_since_epoch()
132 bool is_sha256(std::string_view value) {
133 return value.size() == 64
134 && std::ranges::all_of(value, [](
const unsigned char character) {
135 return std::isdigit(character) != 0
136 || (character >=
'a' && character <=
'f')
137 || (character >=
'A' && character <=
'F');
141 bool is_safe_identifier(std::string_view value) {
142 return !value.empty() && value.size() <= 128
143 && std::ranges::all_of(value, [](
const unsigned char character) {
144 return std::isalnum(character) != 0 || character ==
'-'
145 || character ==
'_' || character ==
'.'
150 bool is_logical_date(std::string_view value) {
151 if (value.size() != 10 || value[4] !=
'-' || value[7] !=
'-') {
154 for (
const std::size_t index : { 0U, 1U, 2U, 3U, 5U, 6U, 8U, 9U }) {
155 if (value[index] <
'0' || value[index] >
'9') {
159 const int year_value = (value[0] -
'0') * 1000 + (value[1] -
'0') * 100
160 + (value[2] -
'0') * 10 + (value[3] -
'0');
161 const unsigned month_value
162 =
static_cast<
unsigned>((value[5] -
'0') * 10 + (value[6] -
'0'));
163 const unsigned day_value
164 =
static_cast<
unsigned>((value[8] -
'0') * 10 + (value[9] -
'0'));
165 return std::chrono::year_month_day {
166 std::chrono::year { year_value },
167 std::chrono::month { month_value }, std::chrono::day { day_value }
171 void validate_run_claim(
172 std::string_view run_id, std::string_view graph_domain,
173 std::string_view logical_date, std::string_view configuration_sha256
175 if (!is_safe_identifier(run_id)) {
176 throw std::invalid_argument(
177 "run_id must be a safe stable identifier"
180 if (graph_domain !=
"product_graph"
181 && graph_domain !=
"research_candidate_graph") {
182 throw std::invalid_argument(
"unknown graph domain");
184 if (!is_logical_date(logical_date)) {
185 throw std::invalid_argument(
186 "logical_date must be a valid YYYY-MM-DD"
189 if (!is_sha256(configuration_sha256)) {
190 throw std::invalid_argument(
191 "configuration_sha256 must be a SHA-256 digest"
196 std::string sanitize_filename(std::string value) {
197 for (
char& character : value) {
198 const unsigned char byte =
static_cast<
unsigned char>(character);
199 if (!std::isalnum(byte) && character !=
'.' && character !=
'-'
200 && character !=
'_') {
204 while (!value.empty() && value.front() ==
'.') {
205 value.erase(value.begin());
207 if (value.empty() || value ==
"." || value ==
"..") {
208 return "submission.bin";
213 void reject_symlink_components(
214 const std::filesystem::path& path, std::string_view context
216 std::error_code error;
217 const auto absolute = std::filesystem::absolute(path, error);
219 throw std::runtime_error(
220 "cannot resolve " + std::string(context) +
": "
224 std::filesystem::path current;
225 for (
const auto& component : absolute.lexically_normal()) {
226 current /= component;
227 const auto status = std::filesystem::symlink_status(current, error);
229 if (error == std::errc::no_such_file_or_directory) {
232 throw std::runtime_error(
233 "cannot inspect " + std::string(context) +
": "
237 if (status.type() == std::filesystem::file_type::not_found) {
240 if (std::filesystem::is_symlink(status)) {
241 throw std::invalid_argument(
242 std::string(context) +
" must not traverse a symbolic link"
248 std::filesystem::path
249 weakly_canonical_or_absolute(
const std::filesystem::path& path) {
250 std::error_code error;
251 const auto absolute = std::filesystem::absolute(path, error);
253 throw std::runtime_error(
"cannot resolve path: " + path.string());
255 const auto result = std::filesystem::weakly_canonical(absolute, error);
257 throw std::runtime_error(
258 "cannot safely canonicalize path " + path.string() +
": "
262 return result.lexically_normal();
265 struct file_fingerprint {
267 std::uintmax_t byte_length = 0;
270 file_fingerprint fingerprint_regular_file(
271 const std::filesystem::path& path, std::uintmax_t max_bytes,
272 std::string_view context
274 std::error_code error;
275 const auto status = std::filesystem::symlink_status(path, error);
276 if (error || std::filesystem::is_symlink(status)
277 || !std::filesystem::is_regular_file(status)) {
278 throw std::invalid_argument(
279 std::string(context) +
" is not a regular non-symlink file"
282 const auto size_before = std::filesystem::file_size(path, error);
284 throw std::runtime_error(
285 "cannot size " + std::string(context) +
": " + error.message()
288 if (size_before > max_bytes
289 || size_before >
static_cast<std::uintmax_t>(
290 std::numeric_limits<sqlite3_int64>::max()
292 throw std::invalid_argument(
293 std::string(context) +
" exceeds configured byte limit"
296 const auto modified_before
297 = std::filesystem::last_write_time(path, error);
299 throw std::runtime_error(
300 "cannot inspect " + std::string(context) +
": "
305 const auto size_after = std::filesystem::file_size(path, error);
307 throw std::runtime_error(
308 "cannot recheck " + std::string(context) +
": "
312 const auto modified_after
313 = std::filesystem::last_write_time(path, error);
314 if (error || size_before != size_after
315 || modified_before != modified_after) {
316 throw std::runtime_error(
317 std::string(context) +
" changed while it was being read"
320 return { digest, size_after };
323 std::filesystem::path choose_destination(
326 const std::string filename
327 = sanitize_filename(request.source_path.filename().string());
328 std::filesystem::path destination = request.inbox_root / filename;
329 if (!std::filesystem::exists(destination)) {
332 const auto stem = destination.stem().string();
333 const auto extension = destination.extension().string();
334 const auto short_hash = std::string(payload_hash.substr(0, 12));
335 for (std::size_t sequence = 1; sequence < 100000; ++sequence) {
336 destination = request.inbox_root
337 / (stem +
"-" + short_hash +
"-" + std::to_string(sequence)
339 if (!std::filesystem::exists(destination)) {
343 throw std::runtime_error(
344 "cannot allocate a unique internal queue path"
348 std::string portable_payload_reference(
349 const std::filesystem::path& payload,
350 const std::filesystem::path& ledger_path
353 = payload.lexically_relative(ledger_path.parent_path());
354 if (!relative.empty() && !relative.is_absolute()) {
355 return relative.generic_string();
357 return payload.generic_string();
361 sqlite3_stmt* statement,
const std::filesystem::path& ledger_path
365 const std::filesystem::path stored_reference
366 = column_text(statement, 1);
367 result.payload_ref = stored_reference.is_absolute()
368 ? stored_reference.lexically_normal()
369 : std::filesystem::absolute(
370 ledger_path.parent_path() / stored_reference
376 result
.title = column_text(statement, 5);
377 result.status = cocoon_status_from_string(column_text(statement, 6));
378 result.accepted_by = optional_column_text(statement, 7);
379 result.supersedes = optional_column_text(statement, 8);
381 =
static_cast<std::uintmax_t>(sqlite3_column_int64(statement, 9));
385 constexpr std::string_view envelope_select = R"SQL(
386SELECT e.envelope_id, e.payload_ref, e.payload_sha256, e.format_version,
387 e.submission_ref, e.title, s.status, s.accepted_by, e.supersedes,
388 e.byte_length
389FROM envelopes e JOIN envelope_state s USING (envelope_id)
390)SQL";
397 sqlite3* raw =
nullptr;
399 path.string().c_str(), &raw,
400 SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE
401 | SQLITE_OPEN_FULLMUTEX,
405 const std::string message = raw !=
nullptr
406 ? sqlite3_errmsg(raw)
407 :
"cannot open operational ledger";
408 if (raw !=
nullptr) {
411 throw std::runtime_error(message);
414 if (sqlite3_busy_timeout(database.get(), 5000) != SQLITE_OK) {
416 database.get(),
"configure operational-ledger timeout"
419 execute(database.get(),
"PRAGMA foreign_keys = ON;");
420 execute(database.get(),
"PRAGMA journal_mode = WAL;");
421 execute(database.get(),
"PRAGMA synchronous = NORMAL;");
422 execute(database.get(), R"SQL(
423CREATE TABLE IF NOT EXISTS envelopes (
424 envelope_id TEXT PRIMARY KEY,
425 payload_ref TEXT NOT NULL UNIQUE,
426 payload_sha256 TEXT NOT NULL CHECK(length(payload_sha256) = 64),
427 format_version INTEGER NOT NULL,
428 submission_ref TEXT NOT NULL,
429 title TEXT NOT NULL,
430 supersedes TEXT REFERENCES envelopes(envelope_id),
431 byte_length INTEGER NOT NULL CHECK(byte_length >= 0),
432 created_at TEXT NOT NULL
433);
434CREATE TABLE IF NOT EXISTS envelope_state (
435 envelope_id TEXT PRIMARY KEY REFERENCES envelopes(envelope_id),
436 status TEXT NOT NULL,
437 accepted_by TEXT,
438 updated_at TEXT NOT NULL
439);
440CREATE TABLE IF NOT EXISTS state_events (
441 sequence INTEGER PRIMARY KEY AUTOINCREMENT,
442 envelope_id TEXT NOT NULL REFERENCES envelopes(envelope_id),
443 from_status TEXT NOT NULL,
444 to_status TEXT NOT NULL,
445 actor_ref TEXT NOT NULL,
446 reason TEXT NOT NULL,
447 occurred_at TEXT NOT NULL
448);
449CREATE TABLE IF NOT EXISTS inbox_baseline (
450 inbox_root TEXT NOT NULL,
451 relative_path TEXT NOT NULL,
452 sha256 TEXT NOT NULL,
453 byte_length INTEGER NOT NULL,
454 PRIMARY KEY(inbox_root, relative_path)
455);
456CREATE TABLE IF NOT EXISTS runs (
457 run_id TEXT PRIMARY KEY,
458 graph_domain TEXT NOT NULL,
459 logical_date TEXT NOT NULL,
460 configuration_sha256 TEXT NOT NULL,
461 status TEXT NOT NULL CHECK(status IN ('running','succeeded','failed')),
462 attempt_count INTEGER NOT NULL DEFAULT 1 CHECK(attempt_count >= 1),
463 manifest_ref TEXT NOT NULL DEFAULT '',
464 started_at TEXT NOT NULL,
465 finished_at TEXT
466);
467CREATE UNIQUE INDEX IF NOT EXISTS runs_live_logical_date_idx
468ON runs(graph_domain, logical_date)
469WHERE status IN ('running', 'succeeded');
470CREATE TABLE IF NOT EXISTS run_attempts (
471 run_id TEXT NOT NULL REFERENCES runs(run_id),
472 attempt INTEGER NOT NULL CHECK(attempt >= 1),
473 status TEXT NOT NULL CHECK(status IN ('running','succeeded','failed')),
474 started_at TEXT NOT NULL,
475 finished_at TEXT,
476 manifest_ref TEXT NOT NULL DEFAULT '',
477 PRIMARY KEY(run_id, attempt)
478);
479CREATE TABLE IF NOT EXISTS run_inputs (
480 run_id TEXT NOT NULL REFERENCES runs(run_id),
481 envelope_id TEXT NOT NULL REFERENCES envelopes(envelope_id),
482 payload_sha256 TEXT NOT NULL CHECK(length(payload_sha256) = 64),
483 PRIMARY KEY(run_id, envelope_id)
484);
485CREATE INDEX IF NOT EXISTS envelope_state_status_idx
486ON envelope_state(status);
487)SQL");
498 return "needs_format_fix";
500 return "waiting_approval";
504 return "waiting_processing";
520 constexpr std::array statuses {
521 cocoon_status::received, cocoon_status::needs_format_fix,
522 cocoon_status::waiting_approval, cocoon_status::accepted,
523 cocoon_status::waiting_processing, cocoon_status::processing,
524 cocoon_status::integrated, cocoon_status::failed,
525 cocoon_status::rejected, cocoon_status::superseded,
527 for (
const auto status : statuses) {
528 if (to_string(status) == value) {
532 throw std::invalid_argument(
"unknown cocoon status: " + std::string(value));
565 std::filesystem::path database_path,
566 std::optional<std::filesystem::path> legacy_inbox_root
569 reject_symlink_components(database_path,
"operational ledger path");
570 path_ = weakly_canonical_or_absolute(database_path);
571 if (legacy_inbox_root) {
572 reject_symlink_components(*legacy_inbox_root,
"legacy inbox path");
573 legacy_inbox_root_ = weakly_canonical_or_absolute(*legacy_inbox_root);
574 if (path_is_within(path_, *legacy_inbox_root_)) {
575 throw std::invalid_argument(
576 "operational ledger must remain outside the legacy inbox"
580 if (path_.has_parent_path()) {
581 std::filesystem::create_directories(path_.parent_path());
583 impl_ =
new implementation(path_);
589 reject_symlink_components(request.source_path,
"submission path");
590 reject_symlink_components(request.inbox_root,
"inbox path");
591 const auto source = weakly_canonical_or_absolute(request.source_path);
592 const auto inbox = weakly_canonical_or_absolute(request.inbox_root);
594 throw std::invalid_argument(
595 "submission reference and title are required"
598 if (inbox == inbox.root_path() || !inbox.has_parent_path()) {
599 throw std::invalid_argument(
"inbox must be a scoped directory");
601 if (path_is_within(path_, inbox)) {
602 throw std::invalid_argument(
603 "operational ledger must remain outside the internal batch queue"
606 if (legacy_inbox_root_
607 && (path_is_within(inbox, *legacy_inbox_root_)
608 || path_is_within(*legacy_inbox_root_, inbox))) {
609 throw std::invalid_argument(
610 "internal queue and external legacy inbox must not overlap"
613 std::filesystem::create_directories(inbox);
614 reject_symlink_components(inbox,
"inbox path");
615 const auto source_fingerprint = fingerprint_regular_file(
616 source, request.max_payload_bytes,
"submission"
618 const std::string& payload_hash = source_fingerprint.sha256;
619 const std::string envelope_id =
"env_"
621 "batch_envelope_v1\n" + payload_hash +
"\n"
627 auto existing = get(envelope_id);
628 if (existing.payload_sha256 != payload_hash
630 || existing.title != request
.title
631 || existing.supersedes != request.supersedes) {
632 throw std::logic_error(
"stable envelope identity collision");
634 if (!path_is_within(existing.payload_ref, inbox)) {
635 throw std::runtime_error(
636 "registered cocoon payload escaped the internal queue"
639 reject_symlink_components(
640 existing.payload_ref,
"registered cocoon payload"
642 std::error_code retained_error;
643 const auto retained_status = std::filesystem::symlink_status(
644 existing.payload_ref, retained_error
646 if (retained_status.type() == std::filesystem::file_type::not_found
647 || retained_error == std::errc::no_such_file_or_directory) {
653 throw std::runtime_error(
654 "queued cocoon payload disappeared before integration"
657 const auto retained = fingerprint_regular_file(
658 existing.payload_ref, request.max_payload_bytes,
659 "registered cocoon payload"
661 if (retained.sha256 != existing.payload_sha256
662 || retained.byte_length != existing.byte_length) {
663 throw std::runtime_error(
664 "registered internal queue payload was modified"
668 const auto after_validation = fingerprint_regular_file(
669 existing.payload_ref, request.max_payload_bytes,
670 "registered cocoon payload"
672 if (after_validation.sha256 != retained.sha256
673 || after_validation.byte_length != retained.byte_length) {
674 throw std::runtime_error(
675 "registered inbox payload changed during validation"
681 envelope_id, target,
"arachne:intake",
682 "opaque payload received and queued for explicit approval"
684 }
catch (
const std::logic_error&) {
685 existing = get(envelope_id);
686 if (existing.status == target) {
693 }
catch (
const std::out_of_range&) { }
695 std::filesystem::path payload_ref;
696 file_fingerprint retained;
697 if (path_is_within(source, inbox)) {
698 payload_ref = source;
699 retained = source_fingerprint;
701 auto normalized_request = request;
702 normalized_request.inbox_root = inbox;
703 payload_ref = choose_destination(normalized_request, payload_hash);
704 const auto staging_root
705 = inbox.parent_path() /
".arachne-intake-staging";
706 reject_symlink_components(staging_root,
"intake staging path");
707 std::filesystem::create_directories(staging_root);
708 reject_symlink_components(staging_root,
"intake staging path");
709 const auto sequence = intake_stage_sequence.fetch_add(1);
710 const auto staging = staging_root
711 / (envelope_id +
"-" + std::to_string(unix_seconds()) +
"-"
712 + std::to_string(sequence) +
".part");
714 std::filesystem::copy_file(
715 source, staging, std::filesystem::copy_options::none
717 const auto staged = fingerprint_regular_file(
718 staging, request.max_payload_bytes,
"staged inbox copy"
720 if (staged.sha256 != payload_hash
721 || staged.byte_length != source_fingerprint.byte_length) {
722 throw std::runtime_error(
"inbox copy hash or size mismatch");
726 std::filesystem::create_hard_link(staging, payload_ref);
727 if (!std::filesystem::remove(staging)) {
728 throw std::runtime_error(
"cannot retire intake staging link");
731 std::error_code ignored;
732 std::filesystem::remove(staging, ignored);
735 retained = fingerprint_regular_file(
736 payload_ref, request.max_payload_bytes,
"retained inbox payload"
738 if (retained.sha256 != payload_hash
739 || retained.byte_length != source_fingerprint.byte_length) {
740 throw std::runtime_error(
741 "retained inbox payload changed during publication"
746 const auto final_fingerprint = fingerprint_regular_file(
747 payload_ref, request.max_payload_bytes,
"retained inbox payload"
749 if (final_fingerprint.sha256 != retained.sha256
750 || final_fingerprint.byte_length != retained.byte_length) {
751 throw std::runtime_error(
752 "retained inbox payload changed during mechanical validation"
755 retained = final_fingerprint;
756 const auto created_at = utc_timestamp();
757 sqlite3* database =
impl_->database.get();
758 execute(database,
"BEGIN IMMEDIATE;");
760 auto statement = prepare(database, R"SQL(
761INSERT INTO envelopes(
762 envelope_id, payload_ref, payload_sha256, format_version,
763 submission_ref, title, supersedes, byte_length, created_at
764) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
765)SQL");
766 bind_text(database, statement.get(), 1, envelope_id);
768 database, statement.get(), 2,
769 portable_payload_reference(payload_ref, path_)
771 bind_text(database, statement.get(), 3, payload_hash);
772 if (sqlite3_bind_int(statement.get(), 4, 0) != SQLITE_OK) {
773 throw_sqlite(database,
"bind format version");
776 bind_text(database, statement.get(), 6, request
.title);
777 bind_optional(database, statement.get(), 7, request.supersedes);
778 if (sqlite3_bind_int64(
780 static_cast<sqlite3_int64>(retained.byte_length)
783 throw_sqlite(database,
"bind payload length");
785 bind_text(database, statement.get(), 9, created_at);
786 step_done(database, statement.get());
788 auto state_statement = prepare(database, R"SQL(
789INSERT INTO envelope_state(envelope_id, status, accepted_by, updated_at)
790VALUES (?, 'received', NULL, ?)
791)SQL");
792 bind_text(database, state_statement.get(), 1, envelope_id);
793 bind_text(database, state_statement.get(), 2, created_at);
794 step_done(database, state_statement.get());
795 execute(database,
"COMMIT;");
797 execute(database,
"ROLLBACK;");
802 envelope_id, cocoon_status::waiting_approval,
"arachne:intake",
803 "opaque payload received and queued for explicit approval"
808 const std::string_view envelope_id,
const cocoon_status next,
809 const std::string_view actor_ref,
const std::string_view reason
811 if (actor_ref.empty()) {
812 throw std::invalid_argument(
813 "state transition requires an actor reference"
816 sqlite3* database =
impl_->database.get();
817 execute(database,
"BEGIN IMMEDIATE;");
819 auto current_statement = prepare(database, R"SQL(
820SELECT status FROM envelope_state WHERE envelope_id = ?
821)SQL");
822 bind_text(database, current_statement.get(), 1, envelope_id);
823 if (sqlite3_step(current_statement.get()) != SQLITE_ROW) {
824 throw std::out_of_range(
"unknown envelope");
826 const auto current = cocoon_status_from_string(
827 column_text(current_statement.get(), 0)
829 if (!can_transition(current, next)) {
830 throw std::logic_error(
831 "invalid cocoon transition from "
832 + std::string(to_string(current)) +
" to "
833 + std::string(to_string(next))
836 const auto occurred_at = utc_timestamp();
837 auto update = prepare(database, R"SQL(
838UPDATE envelope_state
839SET status = ?, accepted_by = CASE WHEN ? = 'accepted' THEN ? ELSE accepted_by END,
840 updated_at = ?
841WHERE envelope_id = ?
842)SQL");
843 bind_text(database, update.get(), 1, to_string(next));
844 bind_text(database, update.get(), 2, to_string(next));
846 bind_text(database, update.get(), 3, actor_ref);
847 }
else if (sqlite3_bind_null(update.get(), 3) != SQLITE_OK) {
848 throw_sqlite(database,
"bind accepted by");
850 bind_text(database, update.get(), 4, occurred_at);
851 bind_text(database, update.get(), 5, envelope_id);
852 step_done(database, update.get());
854 auto event = prepare(database, R"SQL(
855INSERT INTO state_events(
856 envelope_id, from_status, to_status, actor_ref, reason, occurred_at
857) VALUES (?, ?, ?, ?, ?, ?)
858)SQL");
859 bind_text(database, event.get(), 1, envelope_id);
860 bind_text(database, event.get(), 2, to_string(current));
861 bind_text(database, event.get(), 3, to_string(next));
862 bind_text(database, event.get(), 4, actor_ref);
863 bind_text(database, event.get(), 5, reason);
864 bind_text(database, event.get(), 6, occurred_at);
865 step_done(database, event.get());
866 execute(database,
"COMMIT;");
868 execute(database,
"ROLLBACK;");
871 return get(envelope_id);
876 sqlite3* database =
impl_->database.get();
877 auto statement = prepare(
878 database, std::string(envelope_select) +
" WHERE e.envelope_id = ?"
880 bind_text(database, statement.get(), 1, envelope_id);
881 if (sqlite3_step(statement.get()) != SQLITE_ROW) {
882 throw std::out_of_range(
"unknown envelope");
884 return read_envelope(statement.get(), path_);
889 sqlite3* database =
impl_->database.get();
890 auto statement = prepare(
892 std::string(envelope_select)
893 +
" WHERE s.status = ? ORDER BY e.created_at, e.envelope_id"
895 bind_text(database, statement.get(), 1, to_string(status));
896 std::vector<envelope_record> result;
897 while (sqlite3_step(statement.get()) == SQLITE_ROW) {
898 result.emplace_back(read_envelope(statement.get(), path_));
905 sqlite3* database =
impl_->database.get();
906 auto statement = prepare(database, R"SQL(
907SELECT sequence, envelope_id, from_status, to_status, actor_ref, reason,
908 occurred_at
909FROM state_events WHERE envelope_id = ? ORDER BY sequence
910)SQL");
911 bind_text(database, statement.get(), 1, envelope_id);
912 std::vector<state_event> result;
913 while (sqlite3_step(statement.get()) == SQLITE_ROW) {
915 { sqlite3_column_int64(statement.get(), 0),
916 column_text(statement.get(), 1),
917 cocoon_status_from_string(column_text(statement.get(), 2)),
918 cocoon_status_from_string(column_text(statement.get(), 3)),
919 column_text(statement.get(), 4), column_text(statement.get(), 5),
920 column_text(statement.get(), 6) }
927 const std::filesystem::path& inbox_root
929 reject_symlink_components(inbox_root,
"legacy inbox path");
930 const auto root = weakly_canonical_or_absolute(inbox_root);
931 if (root == root.root_path()) {
932 throw std::invalid_argument(
"legacy inbox must be a scoped directory");
934 sqlite3* database =
impl_->database.get();
935 execute(database,
"BEGIN IMMEDIATE;");
937 std::vector<std::filesystem::path> paths;
938 for (
const auto& entry :
939 std::filesystem::recursive_directory_iterator(root)) {
940 std::error_code status_error;
941 const auto status = entry.symlink_status(status_error);
942 if (status_error || std::filesystem::is_symlink(status)) {
943 throw std::runtime_error(
944 "legacy inbox baseline encountered an unsafe link"
947 if (std::filesystem::is_regular_file(status)) {
948 paths.push_back(entry.path());
951 std::ranges::sort(paths);
952 for (
const auto& path : paths) {
953 const auto relative_path = path.lexically_relative(root);
954 if (relative_path.empty() || relative_path.is_absolute()
955 || !path_is_within(path, root)) {
956 throw std::runtime_error(
957 "legacy inbox baseline path escaped its root"
960 const auto relative = relative_path.generic_string();
961 const auto fingerprint = fingerprint_regular_file(
962 path, std::numeric_limits<std::uintmax_t>::max(),
965 auto statement = prepare(database, R"SQL(
966INSERT OR IGNORE INTO inbox_baseline(
967 inbox_root, relative_path, sha256, byte_length
968) VALUES (?, ?, ?, ?)
969)SQL");
970 bind_text(database, statement.get(), 1, root.string());
971 bind_text(database, statement.get(), 2, relative);
972 bind_text(database, statement.get(), 3, fingerprint.sha256);
973 if (sqlite3_bind_int64(
975 static_cast<sqlite3_int64>(fingerprint.byte_length)
978 throw_sqlite(database,
"bind inbox file size");
980 step_done(database, statement.get());
982 execute(database,
"COMMIT;");
984 execute(database,
"ROLLBACK;");
990 const std::filesystem::path& inbox_root
992 reject_symlink_components(inbox_root,
"legacy inbox path");
993 const auto root = weakly_canonical_or_absolute(inbox_root);
994 sqlite3* database =
impl_->database.get();
995 auto statement = prepare(database, R"SQL(
996SELECT relative_path, sha256, byte_length FROM inbox_baseline
997WHERE inbox_root = ? ORDER BY relative_path
998)SQL");
999 bind_text(database, statement.get(), 1, root.string());
1000 std::vector<verification_issue> result;
1001 while (sqlite3_step(statement.get()) == SQLITE_ROW) {
1002 const std::filesystem::path relative = column_text(statement.get(), 0);
1003 const auto expected_hash = column_text(statement.get(), 1);
1004 const auto expected_size =
static_cast<std::uintmax_t>(
1005 sqlite3_column_int64(statement.get(), 2)
1007 const auto path = (root / relative).lexically_normal();
1008 if (relative.empty() || relative.is_absolute()
1009 || !path_is_within(path, root)) {
1011 { path,
"recorded legacy inbox path escapes its root" }
1015 std::error_code status_error;
1016 const auto status = std::filesystem::symlink_status(path, status_error);
1017 if (status_error || std::filesystem::is_symlink(status)
1018 || !std::filesystem::is_regular_file(status)) {
1019 result.push_back({ path,
"pre-existing inbox file is missing" });
1022 const auto current = fingerprint_regular_file(
1023 path, std::numeric_limits<std::uintmax_t>::max(),
1026 if (current.byte_length != expected_size) {
1027 result.push_back({ path,
"pre-existing inbox file size changed" });
1030 if (current.sha256 != expected_hash) {
1031 result.push_back({ path,
"pre-existing inbox file hash changed" });
1038 const std::string_view envelope_id,
1039 const std::filesystem::path& internal_queue_root,
1040 std::optional<std::filesystem::path> legacy_inbox_root
1042 reject_symlink_components(internal_queue_root,
"internal queue path");
1043 const auto queue = weakly_canonical_or_absolute(internal_queue_root);
1044 if (queue == queue.root_path()) {
1045 throw std::invalid_argument(
1046 "internal queue must be a scoped directory"
1049 std::optional<std::filesystem::path> legacy = legacy_inbox_root_;
1050 if (legacy_inbox_root) {
1051 reject_symlink_components(*legacy_inbox_root,
"legacy inbox path");
1052 const auto supplied = weakly_canonical_or_absolute(*legacy_inbox_root);
1053 if (legacy && supplied != *legacy) {
1054 throw std::invalid_argument(
1055 "cleanup legacy-inbox boundary differs from ledger "
1062 && (path_is_within(queue, *legacy) || path_is_within(*legacy, queue))) {
1063 throw std::invalid_argument(
1064 "internal queue and external legacy inbox must be disjoint"
1067 const auto envelope = get(envelope_id);
1069 throw std::logic_error(
1070 "queued payload may be retired only after full integration success"
1073 reject_symlink_components(envelope.payload_ref,
"queued payload path");
1074 if (!path_is_within(envelope.payload_ref, queue)) {
1075 throw std::invalid_argument(
1076 "refusing to retire a payload outside the internal queue"
1080 reject_inbox_deletion_target(envelope.payload_ref, *legacy);
1082 std::error_code error;
1084 = std::filesystem::symlink_status(envelope.payload_ref, error);
1085 if (status.type() == std::filesystem::file_type::not_found
1086 || error == std::errc::no_such_file_or_directory) {
1089 if (error || std::filesystem::is_symlink(status)
1090 || !std::filesystem::is_regular_file(status)) {
1091 throw std::runtime_error(
1092 "queued payload is not a removable regular file"
1095 const auto retained = fingerprint_regular_file(
1096 envelope.payload_ref, std::numeric_limits<std::uintmax_t>::max(),
1099 if (retained.sha256 != envelope.payload_sha256
1100 || retained.byte_length != envelope.byte_length) {
1101 throw std::runtime_error(
1102 "queued payload no longer matches its integration record"
1105 if (!std::filesystem::remove(envelope.payload_ref, error) || error) {
1106 throw std::runtime_error(
1107 "cannot retire integrated queue payload: " + error.message()
1114 sqlite3* database =
impl_->database.get();
1115 auto statement = prepare(database, R"SQL(
1116SELECT COUNT(*), COALESCE(SUM(e.byte_length), 0),
1117 COALESCE(MIN(CAST(strftime('%s', s.updated_at) AS INTEGER)), 0)
1118FROM envelopes e JOIN envelope_state s USING(envelope_id)
1119WHERE s.status IN ('accepted', 'waiting_processing', 'failed')
1120)SQL");
1121 if (sqlite3_step(statement.get()) != SQLITE_ROW) {
1122 throw_sqlite(database,
"read accumulation state");
1126 =
static_cast<std::size_t>(sqlite3_column_int64(statement.get(), 0));
1128 =
static_cast<std::uintmax_t>(sqlite3_column_int64(statement.get(), 1));
1129 const auto oldest = sqlite3_column_int64(statement.get(), 2);
1131 result.oldest_age = std::chrono::seconds(
1132 std::max<std::int64_t>(0, unix_seconds() - oldest)
1146 || (policy.oldest_age.count() > 0
1147 && state.oldest_age >= policy.oldest_age);
1151 const std::string_view run_id,
const std::string_view graph_domain,
1152 const std::string_view logical_date,
1153 const std::string_view configuration_sha256,
const bool retry_failed,
1154 const bool resume_running
1157 run_id, graph_domain, logical_date, configuration_sha256
1159 sqlite3* database =
impl_->database.get();
1160 execute(database,
"BEGIN IMMEDIATE;");
1162 int prior_attempts = 0;
1163 std::string prior_status;
1165 auto existing = prepare(database, R"SQL(
1166SELECT graph_domain, logical_date, configuration_sha256, status, attempt_count
1167FROM runs WHERE run_id = ?
1168)SQL");
1169 bind_text(database, existing.get(), 1, run_id);
1170 const int result = sqlite3_step(existing.get());
1171 if (result == SQLITE_ROW) {
1172 if (column_text(existing.get(), 0) != graph_domain
1173 || column_text(existing.get(), 1) != logical_date
1174 || column_text(existing.get(), 2) != configuration_sha256) {
1175 throw std::logic_error(
1176 "run_id is already bound to different run inputs"
1179 prior_status = column_text(existing.get(), 3);
1180 prior_attempts = sqlite3_column_int(existing.get(), 4);
1181 }
else if (result != SQLITE_DONE) {
1182 throw_sqlite(database,
"inspect existing run");
1185 if (!prior_status.empty()) {
1186 if (prior_status ==
"running") {
1187 execute(database,
"COMMIT;");
1188 return resume_running;
1190 if (prior_status !=
"failed" || !retry_failed) {
1191 execute(database,
"COMMIT;");
1195 auto competing = prepare(database, R"SQL(
1196SELECT 1 FROM runs
1197WHERE graph_domain=? AND logical_date=? AND run_id<>?
1198 AND status IN ('running','succeeded') LIMIT 1
1199)SQL");
1200 bind_text(database, competing.get(), 1, graph_domain);
1201 bind_text(database, competing.get(), 2, logical_date);
1202 bind_text(database, competing.get(), 3, run_id);
1203 const int result = sqlite3_step(competing.get());
1204 if (result == SQLITE_ROW) {
1205 execute(database,
"COMMIT;");
1208 if (result != SQLITE_DONE) {
1209 throw_sqlite(database,
"inspect competing logical run");
1212 const std::string started_at = utc_timestamp();
1213 const int next_attempt = prior_attempts + 1;
1214 auto retry = prepare(database, R"SQL(
1215UPDATE runs SET status='running', attempt_count=?, manifest_ref='',
1216 started_at=?, finished_at=NULL WHERE run_id=? AND status='failed'
1217)SQL");
1218 if (sqlite3_bind_int(retry.get(), 1, next_attempt) != SQLITE_OK) {
1219 throw_sqlite(database,
"bind retry attempt");
1221 bind_text(database, retry.get(), 2, started_at);
1222 bind_text(database, retry.get(), 3, run_id);
1223 step_done(database, retry.get());
1224 auto attempt = prepare(database, R"SQL(
1225INSERT INTO run_attempts(run_id, attempt, status, started_at)
1226VALUES (?, ?, 'running', ?)
1227)SQL");
1228 bind_text(database, attempt.get(), 1, run_id);
1229 if (sqlite3_bind_int(attempt.get(), 2, next_attempt) != SQLITE_OK) {
1230 throw_sqlite(database,
"bind retry attempt");
1232 bind_text(database, attempt.get(), 3, started_at);
1233 step_done(database, attempt.get());
1234 execute(database,
"COMMIT;");
1238 bool date_was_claimed =
false;
1239 std::string prior_configuration;
1241 auto prior_date = prepare(database, R"SQL(
1242SELECT configuration_sha256, status FROM runs
1243WHERE graph_domain=? AND logical_date=?
1244ORDER BY started_at DESC, run_id DESC LIMIT 1
1245)SQL");
1246 bind_text(database, prior_date.get(), 1, graph_domain);
1247 bind_text(database, prior_date.get(), 2, logical_date);
1248 const int result = sqlite3_step(prior_date.get());
1249 if (result == SQLITE_ROW) {
1250 date_was_claimed =
true;
1251 prior_configuration = column_text(prior_date.get(), 0);
1252 const std::string status = column_text(prior_date.get(), 1);
1253 if (status ==
"running" || status ==
"succeeded") {
1254 execute(database,
"COMMIT;");
1257 }
else if (result != SQLITE_DONE) {
1258 throw_sqlite(database,
"inspect logical-date guard");
1261 if (date_was_claimed) {
1262 if (prior_configuration != configuration_sha256) {
1263 throw std::logic_error(
1264 "failed logical run used a different configuration"
1267 if (!retry_failed) {
1268 execute(database,
"COMMIT;");
1273 const std::string started_at = utc_timestamp();
1274 auto statement = prepare(database, R"SQL(
1275INSERT INTO runs(
1276 run_id, graph_domain, logical_date, configuration_sha256, status,
1277 attempt_count, started_at
1278) VALUES (?, ?, ?, ?, 'running', 1, ?)
1279)SQL");
1280 bind_text(database, statement.get(), 1, run_id);
1281 bind_text(database, statement.get(), 2, graph_domain);
1282 bind_text(database, statement.get(), 3, logical_date);
1283 bind_text(database, statement.get(), 4, configuration_sha256);
1284 bind_text(database, statement.get(), 5, started_at);
1285 step_done(database, statement.get());
1286 auto attempt = prepare(database, R"SQL(
1287INSERT INTO run_attempts(run_id, attempt, status, started_at)
1288VALUES (?, 1, 'running', ?)
1289)SQL");
1290 bind_text(database, attempt.get(), 1, run_id);
1291 bind_text(database, attempt.get(), 2, started_at);
1292 step_done(database, attempt.get());
1293 execute(database,
"COMMIT;");
1296 execute(database,
"ROLLBACK;");
1302 const std::string_view run_id,
const std::vector<std::string>& envelope_ids
1304 if (!is_safe_identifier(run_id)) {
1305 throw std::invalid_argument(
"run_id must be a safe stable identifier");
1307 if (envelope_ids.empty()) {
1308 throw std::invalid_argument(
"product run requires at least one input");
1310 std::vector<std::string> requested = envelope_ids;
1311 std::ranges::sort(requested);
1312 if (std::ranges::adjacent_find(requested) != requested.end()) {
1313 throw std::invalid_argument(
"product run inputs must be unique");
1316 sqlite3* database =
impl_->database.get();
1317 execute(database,
"BEGIN IMMEDIATE;");
1320 auto run = prepare(database, R"SQL(
1321SELECT graph_domain, status FROM runs WHERE run_id=?
1322)SQL");
1323 bind_text(database, run.get(), 1, run_id);
1324 if (sqlite3_step(run.get()) != SQLITE_ROW) {
1325 throw std::logic_error(
"product run is unknown");
1327 if (column_text(run.get(), 0) !=
"product_graph"
1328 || column_text(run.get(), 1) !=
"running") {
1329 throw std::logic_error(
1330 "product run inputs require a running product_graph run"
1335 std::vector<std::pair<std::string, std::string>> existing;
1337 auto inputs = prepare(database, R"SQL(
1338SELECT envelope_id, payload_sha256 FROM run_inputs
1339WHERE run_id=? ORDER BY envelope_id
1340)SQL");
1341 bind_text(database, inputs.get(), 1, run_id);
1342 while (sqlite3_step(inputs.get()) == SQLITE_ROW) {
1343 existing.emplace_back(
1344 column_text(inputs.get(), 0), column_text(inputs.get(), 1)
1349 if (!existing.empty()) {
1350 if (existing.size() != requested.size()) {
1351 throw std::logic_error(
1352 "running product run is already bound to different inputs"
1355 for (std::size_t index = 0; index < requested.size(); ++index) {
1356 const auto envelope = get(requested[index]);
1357 if (existing[index].first != requested[index]
1358 || existing[index].second != envelope.payload_sha256) {
1359 throw std::logic_error(
1360 "running product run input identity changed"
1364 execute(database,
"COMMIT;");
1368 for (
const std::string& envelope_id : requested) {
1369 const auto envelope = get(envelope_id);
1370 switch (envelope.status) {
1371 case cocoon_status::accepted:
1372 case cocoon_status::waiting_processing:
1373 case cocoon_status::processing:
1374 case cocoon_status::integrated:
1375 case cocoon_status::failed:
1377 case cocoon_status::received:
1378 case cocoon_status::needs_format_fix:
1379 case cocoon_status::waiting_approval:
1380 case cocoon_status::rejected:
1381 case cocoon_status::superseded:
1382 throw std::logic_error(
1383 "product run input is not eligible for materialization"
1386 auto insert = prepare(database, R"SQL(
1387INSERT INTO run_inputs(run_id, envelope_id, payload_sha256)
1388VALUES (?, ?, ?)
1389)SQL");
1390 bind_text(database, insert.get(), 1, run_id);
1391 bind_text(database, insert.get(), 2, envelope_id);
1392 bind_text(database, insert.get(), 3, envelope.payload_sha256);
1393 step_done(database, insert.get());
1395 execute(database,
"COMMIT;");
1397 execute(database,
"ROLLBACK;");
1404 if (!is_safe_identifier(run_id)) {
1405 throw std::invalid_argument(
"run_id must be a safe stable identifier");
1407 sqlite3* database =
impl_->database.get();
1409 auto run = prepare(database,
"SELECT 1 FROM runs WHERE run_id=?");
1410 bind_text(database, run.get(), 1, run_id);
1411 if (sqlite3_step(run.get()) != SQLITE_ROW) {
1412 throw std::out_of_range(
"unknown run");
1415 auto statement = prepare(database, R"SQL(
1416SELECT e.envelope_id, e.payload_ref, e.payload_sha256, e.format_version,
1417 e.submission_ref, e.title, s.status, s.accepted_by, e.supersedes,
1418 e.byte_length, i.payload_sha256
1419FROM run_inputs i
1420JOIN envelopes e USING(envelope_id)
1421JOIN envelope_state s USING(envelope_id)
1422WHERE i.run_id=? ORDER BY e.created_at, e.envelope_id
1423)SQL");
1424 bind_text(database, statement.get(), 1, run_id);
1425 std::vector<envelope_record> result;
1426 while (sqlite3_step(statement.get()) == SQLITE_ROW) {
1427 if (column_text(statement.get(), 2)
1428 != column_text(statement.get(), 10)) {
1429 throw std::logic_error(
"bound product input hash changed");
1431 result.emplace_back(read_envelope(statement.get(), path_));
1437 const std::string_view run_id,
const std::string_view manifest_ref
1439 if (!is_safe_identifier(run_id)) {
1440 throw std::invalid_argument(
"run_id must be a safe stable identifier");
1442 if (manifest_ref.empty()) {
1443 throw std::invalid_argument(
1444 "integrated product run requires a run manifest reference"
1447 sqlite3* database =
impl_->database.get();
1448 execute(database,
"BEGIN IMMEDIATE;");
1450 std::string run_status;
1451 std::string prior_manifest;
1452 int attempt_number = 0;
1454 auto run = prepare(database, R"SQL(
1455SELECT graph_domain, status, attempt_count, manifest_ref
1456FROM runs WHERE run_id=?
1457)SQL");
1458 bind_text(database, run.get(), 1, run_id);
1459 if (sqlite3_step(run.get()) != SQLITE_ROW) {
1460 throw std::logic_error(
"product run is unknown");
1462 if (column_text(run.get(), 0) !=
"product_graph") {
1463 throw std::logic_error(
"run is not a product_graph run");
1465 run_status = column_text(run.get(), 1);
1466 attempt_number = sqlite3_column_int(run.get(), 2);
1467 prior_manifest = column_text(run.get(), 3);
1470 std::vector<std::pair<std::string, cocoon_status>> inputs;
1472 auto query = prepare(database, R"SQL(
1473SELECT i.envelope_id, s.status
1474FROM run_inputs i JOIN envelope_state s USING(envelope_id)
1475WHERE i.run_id=? ORDER BY i.envelope_id
1476)SQL");
1477 bind_text(database, query.get(), 1, run_id);
1478 while (sqlite3_step(query.get()) == SQLITE_ROW) {
1479 inputs.emplace_back(
1480 column_text(query.get(), 0),
1481 cocoon_status_from_string(column_text(query.get(), 1))
1485 if (inputs.empty()) {
1486 throw std::logic_error(
"product run has no bound inputs");
1488 for (
const auto& [envelope_id, status] : inputs) {
1490 if (status != cocoon_status::processing
1491 && status != cocoon_status::integrated) {
1492 throw std::logic_error(
1493 "product run input is neither processing nor integrated"
1497 if (run_status ==
"succeeded") {
1498 if (prior_manifest != manifest_ref
1499 || std::ranges::any_of(inputs, [](
const auto& input) {
1500 return input.second != cocoon_status::integrated;
1502 throw std::logic_error(
1503 "completed product run differs from reconciliation request"
1506 execute(database,
"COMMIT;");
1509 if (run_status !=
"running") {
1510 throw std::logic_error(
1511 "only a running product run can confirm integration"
1515 const std::string finished_at = utc_timestamp();
1516 for (
const auto& [envelope_id, status] : inputs) {
1517 if (status == cocoon_status::integrated) {
1520 auto update = prepare(database, R"SQL(
1521UPDATE envelope_state SET status='integrated', updated_at=?
1522WHERE envelope_id=? AND status='processing'
1523)SQL");
1524 bind_text(database, update.get(), 1, finished_at);
1525 bind_text(database, update.get(), 2, envelope_id);
1526 step_done(database, update.get());
1527 if (sqlite3_changes(database) != 1) {
1528 throw std::logic_error(
1529 "product input changed during activation reconciliation"
1532 auto event = prepare(database, R"SQL(
1533INSERT INTO state_events(
1534 envelope_id, from_status, to_status, actor_ref, reason, occurred_at
1535) VALUES (?, 'processing', 'integrated', 'arachne:product-reconcile', ?, ?)
1536)SQL");
1537 bind_text(database, event.get(), 1, envelope_id);
1539 database, event.get(), 2,
1540 "Penelope activation confirmed; ledger reconciled atomically"
1542 bind_text(database, event.get(), 3, finished_at);
1543 step_done(database, event.get());
1546 auto run_update = prepare(database, R"SQL(
1547UPDATE runs SET status='succeeded', manifest_ref=?, finished_at=?
1548WHERE run_id=? AND status='running'
1549)SQL");
1550 bind_text(database, run_update.get(), 1, manifest_ref);
1551 bind_text(database, run_update.get(), 2, finished_at);
1552 bind_text(database, run_update.get(), 3, run_id);
1553 step_done(database, run_update.get());
1554 if (sqlite3_changes(database) != 1) {
1555 throw std::logic_error(
1556 "running product run changed during completion"
1559 auto attempt = prepare(database, R"SQL(
1560UPDATE run_attempts SET status='succeeded', manifest_ref=?, finished_at=?
1561WHERE run_id=? AND attempt=? AND status='running'
1562)SQL");
1563 bind_text(database, attempt.get(), 1, manifest_ref);
1564 bind_text(database, attempt.get(), 2, finished_at);
1565 bind_text(database, attempt.get(), 3, run_id);
1566 if (sqlite3_bind_int(attempt.get(), 4, attempt_number) != SQLITE_OK) {
1567 throw_sqlite(database,
"bind product run attempt number");
1569 step_done(database, attempt.get());
1570 if (sqlite3_changes(database) != 1) {
1571 throw std::logic_error(
1572 "running product attempt provenance is missing"
1575 execute(database,
"COMMIT;");
1577 execute(database,
"ROLLBACK;");
1583 const std::string_view run_id,
const std::string_view status,
1584 const std::string_view manifest_ref
1586 if (status !=
"succeeded" && status !=
"failed") {
1587 throw std::invalid_argument(
"run status must be succeeded or failed");
1589 if (!is_safe_identifier(run_id)) {
1590 throw std::invalid_argument(
"run_id must be a safe stable identifier");
1592 sqlite3* database =
impl_->database.get();
1593 execute(database,
"BEGIN IMMEDIATE;");
1595 int attempt_number = 0;
1597 auto current = prepare(database, R"SQL(
1598SELECT attempt_count FROM runs WHERE run_id=? AND status='running'
1599)SQL");
1600 bind_text(database, current.get(), 1, run_id);
1601 const int result = sqlite3_step(current.get());
1602 if (result != SQLITE_ROW) {
1603 if (result != SQLITE_DONE) {
1604 throw_sqlite(database,
"inspect running run");
1606 throw std::logic_error(
"run is unknown or already finished");
1608 attempt_number = sqlite3_column_int(current.get(), 0);
1610 const std::string finished_at = utc_timestamp();
1611 auto statement = prepare(database, R"SQL(
1612UPDATE runs SET status = ?, manifest_ref = ?, finished_at = ?
1613WHERE run_id = ? AND status = 'running'
1614)SQL");
1615 bind_text(database, statement.get(), 1, status);
1616 bind_text(database, statement.get(), 2, manifest_ref);
1617 bind_text(database, statement.get(), 3, finished_at);
1618 bind_text(database, statement.get(), 4, run_id);
1619 step_done(database, statement.get());
1620 auto attempt = prepare(database, R"SQL(
1621UPDATE run_attempts SET status=?, manifest_ref=?, finished_at=?
1622WHERE run_id=? AND attempt=? AND status='running'
1623)SQL");
1624 bind_text(database, attempt.get(), 1, status);
1625 bind_text(database, attempt.get(), 2, manifest_ref);
1626 bind_text(database, attempt.get(), 3, finished_at);
1627 bind_text(database, attempt.get(), 4, run_id);
1628 if (sqlite3_bind_int(attempt.get(), 5, attempt_number) != SQLITE_OK) {
1629 throw_sqlite(database,
"bind run attempt number");
1631 step_done(database, attempt.get());
1632 if (sqlite3_changes(database) != 1) {
1633 throw std::logic_error(
"running attempt provenance is missing");
1635 execute(database,
"COMMIT;");
1637 execute(database,
"ROLLBACK;");
1647 const std::filesystem::path& path,
1648 const std::filesystem::path& possible_parent
1650 const auto child = weakly_canonical_or_absolute(path);
1651 const auto parent = weakly_canonical_or_absolute(possible_parent);
1652 auto child_it = child.begin();
1653 for (
auto parent_it = parent.begin(); parent_it != parent.end();
1654 ++parent_it, ++child_it) {
1655 if (child_it == child.end() || *child_it != *parent_it) {
1662void reject_inbox_deletion_target(
1663 const std::filesystem::path& target,
const std::filesystem::path& inbox_root
1665 if (path_is_within(target, inbox_root)
1666 || path_is_within(inbox_root, target)) {
1667 throw std::invalid_argument(
1668 "refusing deletion inside or above the external legacy inbox"
1674 const std::filesystem::path& lock_root,
const std::string_view graph_domain,
1675 const std::string_view run_id,
const std::chrono::seconds stale_after
1677 if (graph_domain !=
"product_graph"
1678 && graph_domain !=
"research_candidate_graph") {
1679 throw std::invalid_argument(
"unknown graph domain");
1681 if (!is_safe_identifier(run_id)) {
1682 throw std::invalid_argument(
"run_id must be a safe stable identifier");
1684 if (stale_after.count() <= 0) {
1685 throw std::invalid_argument(
"lock stale interval must be positive");
1687 reject_symlink_components(lock_root,
"lock root");
1688 std::filesystem::create_directories(lock_root);
1689 reject_symlink_components(lock_root,
"lock root");
1690 const auto root = weakly_canonical_or_absolute(lock_root);
1691 if (root == root.root_path()) {
1692 throw std::invalid_argument(
"lock root must be a scoped directory");
1694 directory_ = root / (std::string(graph_domain) +
".lock");
1695 std::error_code error;
1696 const bool created = std::filesystem::create_directory(directory_, error);
1699 throw std::runtime_error(
1700 "cannot create graph-domain lock: " + error.message()
1703 const auto lock_status
1704 = std::filesystem::symlink_status(directory_, error);
1705 if (error || std::filesystem::is_symlink(lock_status)
1706 || !std::filesystem::is_directory(lock_status)) {
1707 throw std::runtime_error(
1708 "graph-domain lock path is not a safe directory"
1711 const auto lease = directory_ /
"lease.json";
1712 const auto lease_status = std::filesystem::symlink_status(lease, error);
1713 if (error || std::filesystem::is_symlink(lease_status)
1714 || !std::filesystem::is_regular_file(lease_status)) {
1715 throw std::runtime_error(
1716 "graph-domain lock lease is missing or unsafe; maintainer "
1721 std::ifstream input(lease, std::ios::binary);
1722 nlohmann::json document;
1724 throw std::runtime_error(
"cannot read graph-domain lock lease");
1727 if (!document.is_object()
1728 || document.value(
"format_version", 0) != 1
1729 || document.value(
"graph_domain", std::string {})
1731 || !is_safe_identifier(document.value(
"run_id", std::string {}))
1732 || !document.contains(
"acquired_unix")
1733 || !document.at(
"acquired_unix").is_number_integer()) {
1734 throw std::runtime_error(
1735 "graph-domain lock lease is malformed"
1738 if (document.contains(
"ownership_token")
1739 && (!document.at(
"ownership_token").is_string()
1740 || !is_sha256(document.at(
"ownership_token")
1741 .get_ref<
const std::string&>()))) {
1742 throw std::runtime_error(
1743 "graph-domain lock lease ownership token is malformed"
1747 = document.at(
"acquired_unix").get<std::int64_t>();
1748 if (acquired <= 0) {
1749 throw std::runtime_error(
1750 "graph-domain lock lease timestamp is malformed"
1753 if (unix_seconds() - acquired >= stale_after.count()) {
1754 throw std::runtime_error(
1755 "graph-domain lock is stale; maintainer recovery required"
1758 }
catch (
const nlohmann::json::exception&) {
1759 throw std::runtime_error(
"graph-domain lock lease is malformed");
1761 throw std::runtime_error(
"graph domain already has an active writer");
1763 std::random_device entropy;
1765 std::string(run_id) +
"\n" + std::string(graph_domain) +
"\n"
1767 std::chrono::high_resolution_clock::now().time_since_epoch().count()
1769 +
"\n" + std::to_string(lock_token_sequence.fetch_add(1)) +
"\n"
1770 + std::to_string(entropy())
1772 const nlohmann::ordered_json lease {
1773 {
"format_version", 1 },
1774 {
"run_id", run_id },
1775 {
"graph_domain", graph_domain },
1776 {
"acquired_unix", unix_seconds() },
1779 const auto lease_path = directory_ /
"lease.json";
1780 const auto temporary
1781 = directory_ / (
".lease-" + ownership_token_.substr(0, 16) +
".tmp");
1783 std::ofstream output(temporary, std::ios::binary | std::ios::trunc);
1785 throw std::runtime_error(
"cannot write graph-domain lease");
1787 output << lease.dump(2) <<
'\n';
1791 throw std::runtime_error(
"cannot flush graph-domain lease");
1793 std::filesystem::rename(temporary, lease_path, error);
1795 throw std::runtime_error(
1796 "cannot publish graph-domain lease: " + error.message()
1800 std::error_code ignored;
1801 std::filesystem::remove(temporary, ignored);
1802 std::filesystem::remove(lease_path, ignored);
1803 std::filesystem::remove(directory_, ignored);
1825 if (
this != &other) {
1829 directory_ = std::move(other.directory_);
1844 std::error_code error;
1845 const auto status = std::filesystem::symlink_status(directory_, error);
1846 if (error || std::filesystem::is_symlink(status)
1847 || !std::filesystem::is_directory(status)) {
1849 throw std::runtime_error(
1850 "lock path was replaced; refusing destructive release"
1853 std::size_t entries = 0;
1854 for (
const auto& entry : std::filesystem::directory_iterator(directory_)) {
1856 if (entry.path().filename() !=
"lease.json") {
1857 throw std::runtime_error(
1858 "lock directory contains unexpected files; refusing release"
1863 throw std::runtime_error(
1864 "lock lease is missing; refusing destructive release"
1867 const auto lease_path = directory_ /
"lease.json";
1868 const auto lease_status
1869 = std::filesystem::symlink_status(lease_path, error);
1870 if (error || std::filesystem::is_symlink(lease_status)
1871 || !std::filesystem::is_regular_file(lease_status)) {
1872 throw std::runtime_error(
1873 "lock lease is unsafe; refusing destructive release"
1876 nlohmann::json lease;
1878 std::ifstream input(lease_path, std::ios::binary);
1880 throw std::runtime_error(
"cannot read owned graph-domain lease");
1883 }
catch (
const nlohmann::json::exception&) {
1884 throw std::runtime_error(
1885 "owned graph-domain lease is malformed; refusing release"
1888 if (!lease.is_object()
1891 throw std::runtime_error(
1892 "lock ownership changed; refusing destructive release"
1895 if (!std::filesystem::remove(lease_path, error) || error) {
1896 throw std::runtime_error(
1897 "cannot remove graph-domain lease: " + error.message()
1900 if (!std::filesystem::remove(directory_, error) || error) {
1901 throw std::runtime_error(
1902 "cannot release graph-domain lock: " + error.message()