diff --git a/SOURCE_MANIFEST.json b/SOURCE_MANIFEST.json index ee37916..5c8f14a 100644 --- a/SOURCE_MANIFEST.json +++ b/SOURCE_MANIFEST.json @@ -1,6 +1,6 @@ { "schema_version": "thoth-source-preview-manifest-v1", - "created_at": "2026-10-19T10:25:51.945157+00:00", + "created_at": "2026-10-19T13:28:27.492646+00:00", "license": "MIT", "git_history_included": false, "full_suite_verified": false, @@ -1878,7 +1878,7 @@ }, { "path": "config/module-responsibility-budget.json", - "sha256": "428bab7a87160af210a40f3d8c5a8762aa588981bdbbc07cf53c8509f83b7195", + "sha256": "2c8eee608d261d612e04010f1e3e18bd1df3549a91b91d323beee60316924f58", "bytes": 8580 }, { @@ -1948,13 +1948,13 @@ }, { "path": "docs/KNOWN_LIMITATIONS.ko.md", - "sha256": "d8b0bd6598ae913e90c5144b163d6938009845e06c179ca21e56f0cd24b4e33f", - "bytes": 8165 + "sha256": "777febcf50b13b2878658fe826e34c0230d3d9930efbf9cea72897facfcefe60", + "bytes": 8864 }, { "path": "docs/KNOWN_LIMITATIONS.md", - "sha256": "1d782ae99f440fdd981ae120ab33592b8f90d6146d4b47f07d2bb314fac3398e", - "bytes": 7100 + "sha256": "f294c9aaaefcca87c0d61f9704d0bbf3a9a97568bcf00570467eec74128ce113", + "bytes": 7626 }, { "path": "docs/KNOWN_TEST_FAILURES.json", @@ -2508,8 +2508,8 @@ }, { "path": "migrations/env.py", - "sha256": "feb20ec5fb10d3cb726e07d14b1b6c5ae413a18b1072f5de787c930ad3555c54", - "bytes": 1820 + "sha256": "34cf2a8e8da9eeecd7771517a2a8f4cb828404710abea66e502a491e065e4396", + "bytes": 1904 }, { "path": "migrations/script.py.mako", @@ -2561,6 +2561,11 @@ "sha256": "613e8a6fb5593ebb8fea445bd1717211f6c4fdc6d69176e8099c166190a4b9fd", "bytes": 3880 }, + { + "path": "migrations/versions/4c17f5089ea3_operation_owners.py", + "sha256": "11e8a4955e3a8515118e907bbda150856a27b71a8369a10874f1624b15c02781", + "bytes": 702 + }, { "path": "migrations/versions/4edb1326e7ba_control_records.py", "sha256": "24681b809904acd6ff0988ecf6697a44aa35eb73eb822a62833276e7cda97f99", @@ -3731,6 +3736,11 @@ "sha256": "3600657f686c6f21b351205969dfdfd6e5dca7bc5413d738b4d762c1d25479df", "bytes": 9429 }, + { + "path": "src/thoth/adapters/operation_host_identity.py", + "sha256": "35342a6370b5fd7cfaa22b0130bdab496fc5c2fe45f5f843d20706e168e827a9", + "bytes": 3277 + }, { "path": "src/thoth/adapters/parsers/__init__.py", "sha256": "afd09e7f809b052ac67df1bad9d16f4bee4fb3ecce7e6cfdf5b7447f2efda6a8", @@ -4111,10 +4121,20 @@ "sha256": "60ff045f20b5a50cb3e6bf826d1494272e80ea9c5e93a59954726b54917fb5fe", "bytes": 2463 }, + { + "path": "src/thoth/adapters/storage/operation_owner_schema.py", + "sha256": "b3ff285f2e632dd1c0c223b3292606bd531bc7b096d28f83927b141e178c5a7d", + "bytes": 530 + }, + { + "path": "src/thoth/adapters/storage/operation_owner_store.py", + "sha256": "4b6e37108fe02464410bf56a4efd7b2f056a440f9047745776fcd79225970050", + "bytes": 1024 + }, { "path": "src/thoth/adapters/storage/operations.py", - "sha256": "a69dc2b43fdab8bcdf3fb3962e95e9979205fa17e9e38aa6f1282e697b52ec8d", - "bytes": 17632 + "sha256": "88eadd4ad1e2ba972821f30b11bcbf014d3d131f92c57c73d07b97108e33a2e7", + "bytes": 18053 }, { "path": "src/thoth/adapters/storage/outcome.py", @@ -4218,8 +4238,8 @@ }, { "path": "src/thoth/adapters/storage/workspace_owner.py", - "sha256": "39e1eb1688ea5ed18d658d7f4cb3dde7e1950d130111e0e213eeef628d963e42", - "bytes": 6348 + "sha256": "ec0f5d99b5e71ba00fc16f988ace72779788cdaa0a9f1ac011646ebf5f213584", + "bytes": 6935 }, { "path": "src/thoth/adapters/storage/workspace_setup.py", @@ -6168,8 +6188,8 @@ }, { "path": "src/thoth/apps/resource_scope_composition.py", - "sha256": "c43ba984adac460a0add497ead45591c7cea145e8d9e4102d1e912aaaac8ad41", - "bytes": 6964 + "sha256": "f29ad10251d86251ac70f5c6330ef08db1fefa631b3ad66c7e8ed41d0bdfca5a", + "bytes": 7204 }, { "path": "src/thoth/apps/restore_profiles.py", @@ -6188,8 +6208,8 @@ }, { "path": "src/thoth/apps/runtime_entry.py", - "sha256": "981d5168692375e90e79e62cabe2c7f182ef4368791d32121d0b5541ee818d3a", - "bytes": 5011 + "sha256": "b9bc5d885152e0fe4bc084678b8f17810facb37313a4c46651f1ce481413b50d", + "bytes": 6005 }, { "path": "src/thoth/apps/runtime_registration.py", @@ -6638,8 +6658,8 @@ }, { "path": "src/thoth/domain/operation.py", - "sha256": "4cd49438c78a9ab1f7690a02d8a130d5a5d0de1a8fe31054dd80971a93194fb1", - "bytes": 1614 + "sha256": "3838538ce23dfb081da960e2444421f5114a20ec264e068baf972aeeea95ee8b", + "bytes": 1934 }, { "path": "src/thoth/domain/outcome.py", @@ -7203,8 +7223,8 @@ }, { "path": "src/thoth/protocol/bus.py", - "sha256": "96b88ef685f671415e29cdee5a6b5fdd864181da49856bf06f31e0eb42edf780", - "bytes": 31115 + "sha256": "5a6f0012d36f4e719080566385916ce7441977baaaaaa6127f970b8b2f2d06a7", + "bytes": 31488 }, { "path": "src/thoth/protocol/deferred.py", @@ -7233,8 +7253,13 @@ }, { "path": "src/thoth/protocol/operation_claim.py", - "sha256": "7d959c150a371bb2a0e9e2477c50fef1aecc4c5011975809e3531962ee987891", - "bytes": 583 + "sha256": "da6237d8d5619e85cc650b2944730221ac29cb7c15e44d3988658f9c91a37554", + "bytes": 1121 + }, + { + "path": "src/thoth/protocol/operation_owner.py", + "sha256": "8f6da0b375e71715fe8ab4b959b8c41b9be1f7b826075ff0bc937dad4f0b8c73", + "bytes": 8187 }, { "path": "src/thoth/protocol/read_queries.py", @@ -7513,8 +7538,8 @@ }, { "path": "tests/atomicity/harness.py", - "sha256": "2a1c0cdb5a6cc615a2992864e7e469881fa451a86d5172ddf924c275f03116e6", - "bytes": 6014 + "sha256": "77de7714764378821c70ae59a82bc5bdff882b9d7a1c0836efa91138aa3ea812", + "bytes": 8892 }, { "path": "tests/atomicity/outcome_helpers.py", @@ -7921,15 +7946,20 @@ "sha256": "18b4236f9f0d283985bbfc15b170d9c62bfaa328248ea0f7974bbc76b9bf39d3", "bytes": 112407 }, + { + "path": "tests/fixtures/sealable_methods.json", + "sha256": "feba3ef6fcde2e83ba7b5c9c376573a94b02dafaeafcf8da6ce4cefef14efb67", + "bytes": 2350 + }, { "path": "tests/fixtures/storage_migration_ddl.json", - "sha256": "aa74ddf13cc5b40816dc22386a4da0a44603e463a47de30a64c5bc76c22b5352", - "bytes": 109395 + "sha256": "7650d89b2e3d13106ab50fc4cebc691ddbfbc8c059d4871490b6057ef51d6b6d", + "bytes": 109872 }, { "path": "tests/fixtures/storage_schema_contract.json", - "sha256": "f9038cda6f0fce7eaf22022ff337afc5b1c4c71eeb66f268af6aab6c0e866144", - "bytes": 1396654 + "sha256": "7352456044287d3c14955aa29fb7bcb3141fa95a545a3eba9890e8d54623adfa", + "bytes": 1560992 }, { "path": "tests/fixtures/structured_source_fixture.py", @@ -8023,7 +8053,7 @@ }, { "path": "tests/integration/migration_head_helpers.py", - "sha256": "1a20359a8938c37ae8d7afe6f95068642d8af403195263dea9f32316230b7f56", + "sha256": "558317d21741346f81c115e2d2c60ea461d0831b60319593509756f470db7998", "bytes": 698 }, { @@ -8073,8 +8103,8 @@ }, { "path": "tests/integration/schema_contract_probe.py", - "sha256": "477fb8231fcd57aa15c4efed0ffb309e28c5ba284faf82596393561963cbf842", - "bytes": 5725 + "sha256": "c7dd94899f25b7fbdc4f793019ed87cd05a53acbc9bb06275caea145083ad987", + "bytes": 5808 }, { "path": "tests/integration/scoped_runtime.py", @@ -8093,8 +8123,8 @@ }, { "path": "tests/integration/storage_coverage_helpers.py", - "sha256": "1e7e413b5b5debdd04fa7a687e2acd17193c955c976391a060833a1888258644", - "bytes": 2665 + "sha256": "6a18b236a42439bcdecde9cb51de7e0bf079dc388b89a6e90683a87c3a6e31bb", + "bytes": 2689 }, { "path": "tests/integration/synthetic_field_baseline.py", @@ -8363,8 +8393,8 @@ }, { "path": "tests/integration/test_action_step_add_step_id.py", - "sha256": "61d48c1d80db7a43812858e311777764ccf58e038304d9c9bb2463fb8304d4cf", - "bytes": 7319 + "sha256": "0a3fe2fb46a20eca060b926abbe472eafcf4b41e1490e3ee92cc4ef38f6b447d", + "bytes": 7441 }, { "path": "tests/integration/test_active_behavior_consumed.py", @@ -8508,8 +8538,8 @@ }, { "path": "tests/integration/test_d1_object_followup_atomicity.py", - "sha256": "3dac6c905c39f9cd3d566e99af64f338312dfb99771ed3b7eaa9dbdd573b6866", - "bytes": 15698 + "sha256": "ee925b452b50cee46ec4ed634f3b839726e2f0fc4dabd102d02984c11273d995", + "bytes": 15839 }, { "path": "tests/integration/test_d2_counterevidence_stage.py", @@ -8768,13 +8798,13 @@ }, { "path": "tests/integration/test_m3_action_authorization_atomicity.py", - "sha256": "e6d1c1c7c57a80908435d3dba119077b79ceeb54263b66fcdaa92a308d4c0da3", - "bytes": 26211 + "sha256": "a41185e1c90a1e0b2dcd09baade22daff1dc62fd76b85722d52e35022c8e5a29", + "bytes": 26486 }, { "path": "tests/integration/test_m3_action_mutation_atomicity.py", - "sha256": "79f870a268a528ec5140dd868afdabbbeb706ec8066dcd698ce0005d7d937585", - "bytes": 32875 + "sha256": "0d224249ac77c15abb54002a4f19f4e072f1d223143120c514ed801893b74f88", + "bytes": 33150 }, { "path": "tests/integration/test_m3_direct_test_binding_contract.py", @@ -8783,13 +8813,13 @@ }, { "path": "tests/integration/test_m3_execution_effect_atomicity.py", - "sha256": "7838d6056ef61f727247543c5bf9ecf4d01d56e8125fafd3311ba92baa9bde62", - "bytes": 31334 + "sha256": "40242101a1f779344f66d69cae8bff5cc2dee03a462c3bb93bccbdceb628b72b", + "bytes": 31609 }, { "path": "tests/integration/test_m3_execution_revision_atomicity.py", - "sha256": "a30deaf69c7695cbf7233e48bc242a7f7fe4b5225e966c3cef16bd298b316143", - "bytes": 22981 + "sha256": "a897a144f056989f7a94a81b3cc5ae8007c58b1b47bece761185e4e0200c3092", + "bytes": 23256 }, { "path": "tests/integration/test_m3_hypothesis_auxiliary_atomicity.py", @@ -8823,18 +8853,18 @@ }, { "path": "tests/integration/test_m3_object_mutation_atomicity.py", - "sha256": "964b7d293ce48d4127555661f4dfbe0211f3dcdf30ed279fdbbb05f3a8562ca3", - "bytes": 27362 + "sha256": "fee25beeba736d033fcf43ce144cc3dae61cc2800acd17d57cc1ae3f00e39ee6", + "bytes": 27543 }, { "path": "tests/integration/test_m3_outcome_child_atomicity.py", - "sha256": "0f2f2054d3abcf7bb30969ee6fd5bebda42ccdf2cf1561fa7d0d3feaaf4afd2e", - "bytes": 30876 + "sha256": "d072e490208a9f5505179e003b4829e7a912fa1ac01e6d5ec587e292ebbf1538", + "bytes": 31695 }, { "path": "tests/integration/test_m3_outcome_revision_atomicity.py", - "sha256": "f6468d7624162fda66f31900f1f35b225925e456d29c7fab82255ba80a0b1a00", - "bytes": 25504 + "sha256": "8fc75182af37902a10b5541a117192daa8c22955c4a865d669eb2a6b6ef42e58", + "bytes": 25779 }, { "path": "tests/integration/test_m3_prediction_preparation_atomicity.py", @@ -8848,8 +8878,8 @@ }, { "path": "tests/integration/test_m4_memory_control_multiwrite_observation.py", - "sha256": "c3f2bb4eaf2836b958e038d244c3074e016f60f19eb5b352282a1eba96b0e12e", - "bytes": 17723 + "sha256": "49663656a8527b1f8c10311ef1edc731be6bee31e8af56293c4af46b649883ef", + "bytes": 18382 }, { "path": "tests/integration/test_m5_exposure_service_atomicity.py", @@ -9021,6 +9051,31 @@ "sha256": "7b269e5c1382f64823122be46004ea6e0679725d3ba6b6d9644523596e37a927", "bytes": 13419 }, + { + "path": "tests/integration/test_operation_owner_atomicity.py", + "sha256": "c9d5937379847011fd6381efb2b1f2bf5c2447d57df294f347550ac34e5a8a5e", + "bytes": 4492 + }, + { + "path": "tests/integration/test_operation_owner_completion_race.py", + "sha256": "b01b01b8ad62891167943f2893791137f8ea34cc83c5c5ce33ea76952914e1cb", + "bytes": 7531 + }, + { + "path": "tests/integration/test_operation_owner_migration.py", + "sha256": "9d92163feccecd485ebaed50553e9adaa451e26315fae803f9818eb2b219b76b", + "bytes": 4851 + }, + { + "path": "tests/integration/test_operation_owner_recovery.py", + "sha256": "5a8bf32436df6ebf36f7d5dbc25d548ee4087201dd1887592ddd640b02b9138f", + "bytes": 6789 + }, + { + "path": "tests/integration/test_operation_owner_storage.py", + "sha256": "b7ab6e89c25983737be61165c897fe9225313e586379612c118f56f1430843cf", + "bytes": 9563 + }, { "path": "tests/integration/test_operation_started_atomic.py", "sha256": "ec8a58d446540336d6a4e964fbbbbc908428b0bb0aacd0e8882af579c285f01e", @@ -9483,8 +9538,8 @@ }, { "path": "tests/integration/test_resource_scope_transition_boundaries.py", - "sha256": "cd12e11703e24014dc747d000be07fd08bc5a6e416ea9c3bf6b7eb2010949318", - "bytes": 16263 + "sha256": "eecfac5e09c950b271e121060dad638e5aca7526a37713d0c3a7d982444e23e7", + "bytes": 18949 }, { "path": "tests/integration/test_restore_access.py", @@ -9563,8 +9618,8 @@ }, { "path": "tests/integration/test_revision_full_rpc.py", - "sha256": "0ad36d95ea809f99c6e74db0cea6ce5a2560eb0bcb1fdcf056df3e4bbdfe45f5", - "bytes": 22002 + "sha256": "f27a1c5773020ad0d53a78ad63d4a1d9cd48813901cfaf59036c0b7731f67a2b", + "bytes": 22230 }, { "path": "tests/integration/test_revision_merge_late_write_observation.py", @@ -9688,8 +9743,8 @@ }, { "path": "tests/integration/test_stale_running_current_behavior.py", - "sha256": "b64169bb51afebab03c03497fa23b252049dff38954988e504cfe7e1a47d38ab", - "bytes": 13545 + "sha256": "dd6ab402e02900c2e42504d0bc20e28faa8e94b8bb4771cf2a5e3786ba24fa38", + "bytes": 15507 }, { "path": "tests/integration/test_state_transition_noop.py", @@ -9816,6 +9871,11 @@ "sha256": "7b5656a82f03b863c9e2bcce445385d79aac3450a3615c38ddb2e9d42094c194", "bytes": 2668 }, + { + "path": "tests/integration/test_workspace_owner_pin.py", + "sha256": "4b62b04db34039b1ee9c5958b54f6b127b57f3b54fcc02f8fca6646aeaed02f2", + "bytes": 1578 + }, { "path": "tests/integration/test_workspace_restart_storage.py", "sha256": "e6a5ef1002509cdaebae4236fa0df2a48470e6cc1996e0123c8331e583ca5c94", @@ -11146,6 +11206,21 @@ "sha256": "11edc80dabe3d3cb9a210613ea22428052e1be8cb0143c66e1914b355d8a6269", "bytes": 2321 }, + { + "path": "tests/unit/test_operation_host_identity.py", + "sha256": "7cb67880b5deaebf2906087489764f3a80bfc68d95bb8eb33b672ba931626252", + "bytes": 7012 + }, + { + "path": "tests/unit/test_operation_owner_allowances.py", + "sha256": "78165376eab556e94660124f2ad7ca1645176ea52e6610ceb6d138d99f8a6288", + "bytes": 5114 + }, + { + "path": "tests/unit/test_operation_owner_policy.py", + "sha256": "c89a0ce411e1b781e551790c492e44fa44e8c5d365f4eef323626714a8f8246e", + "bytes": 6335 + }, { "path": "tests/unit/test_operation_reentry_registry.py", "sha256": "019af79fa67e596211ce244e2429988d202c4ea7ff32b314e2069219548d7082", diff --git a/config/module-responsibility-budget.json b/config/module-responsibility-budget.json index 52a5a7c..4a6d579 100644 --- a/config/module-responsibility-budget.json +++ b/config/module-responsibility-budget.json @@ -227,7 +227,7 @@ "policy": "over the 600-line limit when it was introduced; do not grow it; lower the baseline when it is split" }, "src/thoth/protocol/bus.py": { - "baseline_lines": 741, + "baseline_lines": 740, "top_level_symbols": [ "DispatchTicket", "PreClaimAuthorization", diff --git a/docs/KNOWN_LIMITATIONS.ko.md b/docs/KNOWN_LIMITATIONS.ko.md index f1b2ea6..7c4f2c7 100644 --- a/docs/KNOWN_LIMITATIONS.ko.md +++ b/docs/KNOWN_LIMITATIONS.ko.md @@ -20,15 +20,23 @@ English: [KNOWN_LIMITATIONS.md](KNOWN_LIMITATIONS.md) 함께 쓰는 폴더나 클라우드 동기화·네트워크 공유로 여러 컴퓨터가 보는 작업 공간은 보호하지 못합니다. 그런 방식으로 같은 작업 공간을 두 곳에서 열지 마세요. 심사용(hosted review) 모드는 이 잠금을 쓰지 않습니다. -## 종료로 중단된 요청이 "실행 중"으로 남을 수 있습니다 - -THOTH는 작업을 시작하기 전에 요청을 "실행 중"으로 기록합니다. 작업 도중 앱이 멈추면 그 기록이 -"실행 중"으로 남을 수 있습니다. 같은 요청을 같은 멱등 key로 다시 보내면 작업을 두 번 하지 않으려고 -다시 시작하지 않고 "실행 중"을 돌려줍니다. 이어서 하려면 새 질문으로 다시 하세요. 새 질문은 -모델 사용량을 다시 쓸 수 있으니, 먼저 이전 결과가 이미 저장됐는지 확인하세요. - -심사용(hosted review) 모드에서는 재시작 뒤 이런 요청이 "이전 호출의 종료를 확인하지 못했다"는 -안내와 함께 닫힐 수도 있습니다. 그 모드의 알려진 한계는 [VERIFICATION.md](VERIFICATION.md)에 있습니다. +## 종료로 중단된 요청의 복구 + +로컬 모드에서는 실측한 67개 명령에 한해, 같은 멱등 key를 다시 보내면 소유 worker의 종료가 +확인된 요청을 실패로 닫을 수 있습니다. 같은 호스트·부팅 식별과 이미 보유한 작업 공간 잠금도 +필요합니다. `OPERATION_OWNER_GONE`과 함께 저장된 내용을 확인하라는 안내를 반환하며, +handler를 다시 실행하거나 과거 요청을 자동으로 훑어 닫지는 않습니다. + +목록 밖의 명령(연구 시작·입력·조정 및 일시정지·재개·중지 포함), 소유 정보가 없는 옛 기록, +호스트 식별이 불명하거나 다른 경우, worker가 살아 있거나 생존 여부가 불명인 경우는 +실행 중으로 남을 수 있습니다. 시간 초과만으로 종료를 판단하지 않습니다. 심사용 모드의 복구 +정책은 그대로입니다. `improvement/evaluation/run`은 쓰기 없는 응답에 명시적인 unchanged 표시가 +없어, 별도 검토 전까지 허용 목록 밖의 후속 후보로 남습니다. + +봉인이 원래 완료와의 경합에서 이기면 도메인 변경은 이미 저장됐어도 일반 변경 알림은 빠질 수 +있습니다. 새 요청을 보내기 전에 저장된 결과를 확인하세요. 새 질문은 모델 사용량을 다시 쓸 수 +있고, 새로 읽은 개정 번호로 보내면 같은 변경이 한 번 더 적용될 수도 있습니다. 이 복구가 +외부 효과의 정확히 한 번 실행을 보장하는 것은 아닙니다. ## 끝난 요청의 후속 활동 기록이 빠질 수 있습니다 diff --git a/docs/KNOWN_LIMITATIONS.md b/docs/KNOWN_LIMITATIONS.md index c70e96a..5f4681f 100644 --- a/docs/KNOWN_LIMITATIONS.md +++ b/docs/KNOWN_LIMITATIONS.md @@ -22,16 +22,23 @@ on a local disk. It does not cover a workspace folder shared between Windows and cloud sync client or a network share exposes to several computers; do not open the same workspace from two places that way. The hosted review mode does not use this lock. -## A request interrupted by a shutdown can stay "running" - -THOTH records a request as running before it starts the work. If the application stops while the -work is in progress, the record can stay "running". Sending the same request again with the same -idempotency key returns "running" instead of starting the work a second time, so that nothing is -done twice. To continue, ask again as a new question. A new question can use model capacity again, -so check first whether the earlier result has already been saved. - -In the hosted review mode a restart can also close such a request with a message that the previous -call could not be confirmed. See [VERIFICATION.md](VERIFICATION.md) for that mode's known limit. +## Recovery of a request interrupted by shutdown + +In LOCAL mode, 67 measured commands can be closed as failed when the same idempotency key is +sent again and the original worker is confirmed gone. THOTH also requires matching host/boot +identity and an already-held workspace lock. It returns `OPERATION_OWNER_GONE` with guidance to +check what was saved; it does not rerun the handler or scan old requests automatically. + +Requests outside that list (including research start/input/steer and pause/resume/stop), old +records without ownership, an unavailable or different host identity, and a live or unknown +worker can remain running. A timeout alone is not proof of death. The hosted review recovery +policy is unchanged. `improvement/evaluation/run` remains outside the list pending separate +review because its no-effect response lacks an explicit unchanged marker. + +If sealing wins a race with the original completion, a domain change may already be saved while +its ordinary change notification is omitted. Check the saved result before sending a new request. +A new question may use model capacity again; a newly refreshed revision guard can also permit a +second change. This recovery is not a guarantee of exactly-once external effects. ## A finished request can miss some follow-up log entries diff --git a/migrations/env.py b/migrations/env.py index f197ebc..075242b 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -8,6 +8,7 @@ from thoth.adapters.storage import behavior_execution_schema as behavior_execution_schema from thoth.adapters.storage import evaluation_run_schema as evaluation_run_schema +from thoth.adapters.storage import operation_owner_schema as operation_owner_schema from thoth.adapters.storage import research_schema as research_schema from thoth.adapters.storage import resource_scope_schema as resource_scope_schema from thoth.adapters.storage import test_validity_schema as test_validity_schema diff --git a/migrations/versions/4c17f5089ea3_operation_owners.py b/migrations/versions/4c17f5089ea3_operation_owners.py new file mode 100644 index 0000000..8e34c1a --- /dev/null +++ b/migrations/versions/4c17f5089ea3_operation_owners.py @@ -0,0 +1,24 @@ +"""Record internal operation process ownership without changing existing rows.""" + +import sqlalchemy as sa +from alembic import op + +revision = "4c17f5089ea3" +down_revision = "3b06e4f87d92" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "operation_owners", + sa.Column("operation_id", sa.String(160), primary_key=True), + sa.Column("worker_id", sa.String(160), nullable=False), + sa.Column("process_id", sa.Integer(), nullable=False), + sa.Column("process_marker", sa.String(160), nullable=False), + sa.Column("host_token", sa.String(160), nullable=False), + ) + + +def downgrade() -> None: + op.drop_table("operation_owners") diff --git a/src/thoth/adapters/operation_host_identity.py b/src/thoth/adapters/operation_host_identity.py new file mode 100644 index 0000000..ea736bd --- /dev/null +++ b/src/thoth/adapters/operation_host_identity.py @@ -0,0 +1,104 @@ +"""Best-effort host/boot equality token; not an authentication or liveness proof.""" + +from __future__ import annotations + +import hashlib +import hmac +import os +import re +import sys +import threading +from pathlib import Path +from uuid import UUID + +from thoth.domain.deployment_mode import DeploymentMode + +_KEY = b"thoth.operation-owner.host.v1" +_cache: tuple[int, str | None] | None = None +_lock = threading.Lock() + + +def _guid(value: str) -> str: + if not re.fullmatch(r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}", value): + raise ValueError("HOST_IDENTITY_FORMAT") + parsed = UUID(value) + if parsed.int == 0: + raise ValueError("HOST_IDENTITY_FORMAT") + return parsed.hex + + +def _windows_identity() -> tuple[str, str]: + import winreg + + access = winreg.KEY_READ | winreg.KEY_WOW64_64KEY + boot_path = ( + r"SYSTEM\CurrentControlSet\Control\Session Manager\Memory Management\PrefetchParameters" + ) + + def boot() -> int: + with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, boot_path, 0, access) as key: + value, kind = winreg.QueryValueEx(key, "BootId") + if kind != winreg.REG_DWORD or type(value) is not int or not 0 <= value <= 2**32 - 1: + raise ValueError("HOST_IDENTITY_FORMAT") + return value + + before = boot() + with winreg.OpenKey( + winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Microsoft\Cryptography", 0, access + ) as key: + machine, kind = winreg.QueryValueEx(key, "MachineGuid") + after = boot() + if before != after or kind != winreg.REG_SZ or not isinstance(machine, str): + raise ValueError("HOST_IDENTITY_UNAVAILABLE") + return _guid(machine), "prefetch-bootid:" + str(before) + + +def _bounded_text(path: str) -> str: + with Path(path).open("rb") as source: + raw = source.read(129) + if len(raw) > 128: + raise ValueError("HOST_IDENTITY_FORMAT") + return raw.decode("ascii").strip() + + +def _linux_identity() -> tuple[str, str]: + machine = _bounded_text("/etc/machine-id") + if not re.fullmatch(r"[0-9a-fA-F]{32}", machine) or int(machine, 16) == 0: + raise ValueError("HOST_IDENTITY_FORMAT") + return machine.lower(), _guid(_bounded_text("/proc/sys/kernel/random/boot_id")) + + +def _read_token() -> str | None: + try: + if sys.platform == "win32": + machine, boot = _windows_identity() + elif sys.platform == "linux": + machine, boot = _linux_identity() + else: + return None + payload = (sys.platform + "\0" + machine + "\0" + boot).encode("utf-8") + return hmac.new(_KEY, payload, hashlib.sha256).hexdigest() + except (OSError, ValueError, TypeError, ImportError): + return None + + +def operation_host_token(mode: DeploymentMode) -> str | None: + """Read once per LOCAL process, caching failure too; HOSTED performs no lookup.""" + if mode is not DeploymentMode.LOCAL: + return None + global _cache + with _lock: + pid = os.getpid() + if _cache is None or _cache[0] != pid: + _cache = (pid, _read_token()) + return _cache[1] + + +def _after_fork() -> None: + global _cache, _lock + _cache = None + _lock = threading.Lock() + + +if hasattr(os, "register_at_fork"): + os.register_at_fork(after_in_child=_after_fork) diff --git a/src/thoth/adapters/storage/operation_owner_schema.py b/src/thoth/adapters/storage/operation_owner_schema.py new file mode 100644 index 0000000..8823c46 --- /dev/null +++ b/src/thoth/adapters/storage/operation_owner_schema.py @@ -0,0 +1,15 @@ +"""Internal process ownership, separate from the public operation columns.""" + +from sqlalchemy import Column, Integer, String, Table + +from thoth.adapters.storage.schema import metadata + +operation_owners = Table( + "operation_owners", + metadata, + Column("operation_id", String(160), primary_key=True), + Column("worker_id", String(160), nullable=False), + Column("process_id", Integer, nullable=False), + Column("process_marker", String(160), nullable=False), + Column("host_token", String(160), nullable=False), +) diff --git a/src/thoth/adapters/storage/operation_owner_store.py b/src/thoth/adapters/storage/operation_owner_store.py new file mode 100644 index 0000000..41b32fd --- /dev/null +++ b/src/thoth/adapters/storage/operation_owner_store.py @@ -0,0 +1,30 @@ +"""Owner persistence on the caller's existing operation transaction.""" + +from sqlalchemy import Connection, insert, select + +from thoth.adapters.storage.operation_owner_schema import operation_owners +from thoth.domain.operation import OperationOwnerRecord + + +def insert_owner( + connection: Connection, operation_id: str, owner: OperationOwnerRecord | None +) -> None: + if owner is not None and owner.host_token is not None: + connection.execute( + insert(operation_owners).values(operation_id=operation_id, **owner.model_dump()) + ) + + +def read_owner(connection: Connection, operation_id: str) -> OperationOwnerRecord | None: + row = ( + connection.execute( + select(operation_owners).where(operation_owners.c.operation_id == operation_id) + ) + .mappings() + .first() + ) + if row is None: + return None + return OperationOwnerRecord.model_validate( + {key: row[key] for key in ("worker_id", "process_id", "process_marker", "host_token")} + ) diff --git a/src/thoth/adapters/storage/operations.py b/src/thoth/adapters/storage/operations.py index b245d50..1fbd098 100644 --- a/src/thoth/adapters/storage/operations.py +++ b/src/thoth/adapters/storage/operations.py @@ -13,6 +13,7 @@ from sqlalchemy.exc import OperationalError from thoth.adapters.storage.events import insert_event_on_connection +from thoth.adapters.storage.operation_owner_store import insert_owner, read_owner from thoth.adapters.storage.resource_scope_schema import operation_resource_bindings from thoth.adapters.storage.schema import ( idempotency_keys, @@ -150,6 +151,8 @@ def _claim( *, started_event: Callable[[], EventRecord] | None = None, ) -> tuple[OperationRecord, bool]: + if candidate.owner is not None and candidate.owner.host_token is None: + candidate = candidate.model_copy(update={"owner": None}) # Logical operation bookkeeping owns its transaction; it must not borrow a # domain transaction that could later commit a partially failed claim. with self._engine.begin() as connection: @@ -203,6 +206,7 @@ def _claim( operation_id=candidate.operation_id, ) ) + insert_owner(connection, candidate.operation_id, candidate.owner) if started_event is not None: insert_event_on_connection(connection, started_event()) return candidate, False @@ -362,7 +366,10 @@ def _read_with_connection( ) if row is None: return None - return self._with_resource_binding(connection, self._deserialize(row)) + record = self._deserialize(row).model_copy( + update={"owner": read_owner(connection, operation_id)} + ) + return self._with_resource_binding(connection, record) @staticmethod def _with_resource_binding( diff --git a/src/thoth/adapters/storage/workspace_owner.py b/src/thoth/adapters/storage/workspace_owner.py index 70e0eba..5a38d08 100644 --- a/src/thoth/adapters/storage/workspace_owner.py +++ b/src/thoth/adapters/storage/workspace_owner.py @@ -14,6 +14,8 @@ import os import sys import threading +from collections.abc import Generator +from contextlib import contextmanager from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path @@ -183,6 +185,26 @@ def acquire_workspace_owner(workspace: Path, *, command: str | None = None) -> W return WorkspaceOwnership(key) +def holds_workspace(workspace: Path) -> bool: + with _GUARD: + return _key(workspace) in _OWNED + + +@contextmanager +def pin_workspace_owner(workspace: Path) -> Generator[bool]: + """Retain only an already-owned lock; never acquire or create a lock file.""" + key = _key(workspace) + with _GUARD: + entry = _OWNED.get(key) + if entry is not None: + entry.count += 1 + try: + yield entry is not None + finally: + if entry is not None: + _release(key) + + def _release(key: str) -> None: with _GUARD: entry = _OWNED.get(key) diff --git a/src/thoth/apps/resource_scope_composition.py b/src/thoth/apps/resource_scope_composition.py index 5671a0d..025b6b6 100644 --- a/src/thoth/apps/resource_scope_composition.py +++ b/src/thoth/apps/resource_scope_composition.py @@ -23,6 +23,7 @@ from thoth.ports.event_store import EventStorePort from thoth.ports.field_measurement import FieldMeasurementObserverPort from thoth.ports.governance import GovernanceStorePort +from thoth.ports.journal import JournalPort from thoth.ports.ledger import ManagedLedgerPort from thoth.ports.operation import OperationStorePort from thoth.ports.project import ProjectStorePort @@ -30,6 +31,7 @@ from thoth.ports.sandbox import SandboxPort from thoth.ports.store_bundle import StoreBundlePort from thoth.protocol.bus import CommandBus +from thoth.protocol.operation_owner import OperationOwner from thoth.protocol.registry import MethodRegistry _LOCAL_SOURCE_KINDS = frozenset({"LOCAL", "GIT"}) @@ -113,6 +115,8 @@ def scope_bus( seal_abandoned_running: bool = False, before_continue: Callable[[str], Awaitable[None]] | None = None, queued_admission: Callable[[OperationRecord], dict[str, JsonValue] | None] | None = None, + owner: OperationOwner | None = None, + journal: JournalPort | None = None, ) -> CommandBus: handlers = OperationCommandHandlers( operations, @@ -132,12 +136,13 @@ def scope_bus( operations, clock, ids, - journal=OperationJournal(events, clock, ids), + journal=journal if journal is not None else OperationJournal(events, clock, ids), measurement=measurement, resource_access=scopes, seal_abandoned_running=seal_abandoned_running, before_continue=before_continue, queued_admission=queued_admission, + owner=owner, ) diff --git a/src/thoth/apps/runtime_entry.py b/src/thoth/apps/runtime_entry.py index dde9f51..30c7601 100644 --- a/src/thoth/apps/runtime_entry.py +++ b/src/thoth/apps/runtime_entry.py @@ -2,13 +2,17 @@ from __future__ import annotations +from functools import partial from pathlib import Path +from thoth.adapters.operation_host_identity import operation_host_token +from thoth.adapters.storage.workspace_owner import holds_workspace, pin_workspace_owner from thoth.application.commands import ThreadCommandHandlers from thoth.application.commands.research_operations import register_research_operations from thoth.application.services import FieldMeasurementService from thoth.application.services.connector_service import ConnectorService from thoth.application.services.full_project_memory import FullProjectMemoryService +from thoth.application.services.operation_journal import OperationJournal from thoth.application.services.scoped_artifacts import ScopedArtifactLedger from thoth.apps.research_entry_composition import ResearchEntryComposition, create_research_entry from thoth.apps.research_runtime import ResearchComponents @@ -26,6 +30,7 @@ from thoth.ports.store_bundle import StoreBundlePort from thoth.ports.task_profile import TaskProfileCatalogPort from thoth.protocol.bus import CommandBus +from thoth.protocol.operation_owner import OperationOwner def assemble_runtime_entry( @@ -77,6 +82,20 @@ def assemble_runtime_entry( from thoth.apps.hosted_review_dispatch import wait_for_hosted_dispatch before_continue = wait_for_hosted_dispatch + journal = OperationJournal(stores.events, clock, ids) + owner = None + if mode is DeploymentMode.LOCAL: + owner = OperationOwner( + worker=research_component.worker, + host_token=operation_host_token(mode), + holds_workspace=partial(holds_workspace, workspace), + pin_workspace=partial(pin_workspace_owner, workspace), + operations=operations, + uow=stores.immediate_uow, + journal=journal, + clock=clock, + resource_access=artifact_ledger.scopes, + ) bus = scope_bus( registration.registry, ledger, @@ -89,6 +108,8 @@ def assemble_runtime_entry( seal_abandoned_running=mode is DeploymentMode.HOSTED_REVIEW, before_continue=before_continue, queued_admission=research_component.entry.queued_admission, + owner=owner, + journal=journal, ) registration.judgment_review.connect(bus, research_component.entry) from thoth.application.commands.research_queue_cancel import QueueAwareOperationCancel diff --git a/src/thoth/domain/operation.py b/src/thoth/domain/operation.py index fd5732f..f694fcc 100644 --- a/src/thoth/domain/operation.py +++ b/src/thoth/domain/operation.py @@ -1,6 +1,6 @@ from __future__ import annotations -from pydantic import AwareDatetime, JsonValue +from pydantic import AwareDatetime, Field, JsonValue from thoth.domain.base import DomainModel from thoth.domain.enums import OperationState @@ -35,6 +35,13 @@ class InternalFailureDiagnostic(DomainModel): post_state: InternalFailureStateSnapshot +class OperationOwnerRecord(DomainModel): + worker_id: str = Field(min_length=1, max_length=160) + process_id: int = Field(gt=0) + process_marker: str = Field(min_length=1, max_length=160) + host_token: Sha256 | None + + class OperationRecord(DomainModel): operation_id: OperationId project_id: ProjectId @@ -53,3 +60,4 @@ class OperationRecord(DomainModel): epoch: int = 0 schema_version: str = "1.0.0" resource_uses: tuple[ResourceUse, ...] | None = None + owner: OperationOwnerRecord | None = Field(default=None, exclude=True, repr=False) diff --git a/src/thoth/protocol/bus.py b/src/thoth/protocol/bus.py index f24c41a..2ebf574 100644 --- a/src/thoth/protocol/bus.py +++ b/src/thoth/protocol/bus.py @@ -12,7 +12,6 @@ from thoth.application.services.historical_access_verification import historical_query_verification from thoth.domain.auth import current_authenticated_actor from thoth.domain.canonical import canonical_payload, domain_digest -from thoth.domain.deployment_mode import STALE_RUNNING_MESSAGE, STALE_RUNNING_REASON from thoth.domain.enums import OperationState from thoth.domain.operation import ( RESEARCH_OPERATION_METHODS, @@ -52,7 +51,8 @@ RpcErrorCode, ) from thoth.protocol.notifications import notifications_for -from thoth.protocol.operation_claim import claim_operation +from thoth.protocol.operation_claim import claim_busy_response, claim_operation +from thoth.protocol.operation_owner import OperationOwner, stale_running_error from thoth.protocol.read_queries import READ_QUERY_METHODS from thoth.protocol.registry import CommandHandler, MethodRegistry @@ -83,6 +83,7 @@ def __init__( seal_abandoned_running: bool = False, before_continue: Callable[[str], Awaitable[None]] | None = None, queued_admission: Callable[[OperationRecord], dict[str, JsonValue] | None] | None = None, + owner: OperationOwner | None = None, ) -> None: self._registry = registry self._operations = operations @@ -94,6 +95,7 @@ def __init__( self._seal_abandoned_running = seal_abandoned_running self._before_continue = before_continue self._queued_admission = queued_admission + self._owner = owner self._operation_task_canceller: Callable[[str], None] | None = None self._failure_diagnostics = ( operations if isinstance(operations, InternalFailureDiagnosticPort) else None @@ -207,11 +209,16 @@ def claim(self, request: JsonRpcRequest) -> DispatchTicket | JsonRpcResponse: ), state=OperationState.RUNNING, created_at=self._clock.now(), + owner=None if self._owner is None else self._owner.record(), ) operation, replayed = claim_operation(self._operations, candidate, self._journal) if replayed: if operation.state == OperationState.RUNNING: self._registry.authorize_reentry(operation) + if self._owner is not None: + sealed = self._owner.seal(request, operation, self._measure) + if sealed is not None: + return self._replay(request.id, sealed) live_task = operation.operation_id in self._research_tasks if ( self._seal_abandoned_running @@ -259,18 +266,7 @@ def claim(self, request: JsonRpcRequest) -> DispatchTicket | JsonRpcResponse: except OperationReentryDenied as exc: return self._reentry_denied(request, exc) except OperationClaimBusy: - return JsonRpcResponse( - id=request.id, - error=JsonRpcError( - code=RpcErrorCode.DOMAIN_REJECTED, - message="operation storage is busy; retry the same request later", - data={ - "reason_code": "OPERATION_CLAIM_BUSY", - "operation_claimed": False, - "retryable": True, - }, - ), - ) + return claim_busy_response(request.id) except IdempotencyConflict as exc: return JsonRpcResponse( id=request.id, @@ -373,11 +369,13 @@ async def _execute(self, ticket: DispatchTicket) -> JsonRpcResponse: ) except (RpcApplicationError, ResourceScopeError) as exc: error = self._application_error(exc) - self._operations.fail( + failed = self._operations.fail( operation.operation_id, error.model_dump(mode="json"), completed_at=self._clock.now(), ) + if failed.error != error.model_dump(mode="json"): + return self._replay(request.id, failed) self._append_event( operation, "operation.failed", @@ -391,11 +389,13 @@ async def _execute(self, ticket: DispatchTicket) -> JsonRpcResponse: message="authenticated authority identity mismatch", data={"reason_code": str(exc), "pre_io": True}, ) - self._operations.fail( + failed = self._operations.fail( operation.operation_id, error.model_dump(mode="json"), completed_at=self._clock.now(), ) + if failed.error != error.model_dump(mode="json"): + return self._replay(request.id, failed) self._append_event( operation, "operation.failed", @@ -414,11 +414,13 @@ async def _execute(self, ticket: DispatchTicket) -> JsonRpcResponse: ) }, ) - self._operations.fail( + failed = self._operations.fail( operation.operation_id, error.model_dump(mode="json"), completed_at=self._clock.now(), ) + if failed.error != error.model_dump(mode="json"): + return self._replay(request.id, failed) self._append_event( operation, "operation.failed", @@ -438,11 +440,13 @@ async def _execute(self, ticket: DispatchTicket) -> JsonRpcResponse: message="internal command failure", data={"operation_id": operation.operation_id}, ) - self._operations.fail( + failed = self._operations.fail( operation.operation_id, error.model_dump(mode="json"), completed_at=self._clock.now(), ) + if failed.error != error.model_dump(mode="json"): + return self._replay(request.id, failed) self._append_event( operation, "operation.failed", @@ -455,6 +459,8 @@ async def _execute(self, ticket: DispatchTicket) -> JsonRpcResponse: durable_value, completed_at=self._clock.now(), ) + if completed.state is not OperationState.SUCCEEDED: + return self._replay(request.id, completed) result_digest = domain_digest("OPERATION_RESULT", "1.0.0", canonical_payload(durable_value)) if self._journal is not None: self._journal.checkpoint( @@ -584,14 +590,7 @@ def _scope_digest(self, request: JsonRpcRequest) -> str: def _fail_abandoned_running( self, request: JsonRpcRequest, operation: OperationRecord ) -> JsonRpcResponse: - error = JsonRpcError( - code=RpcErrorCode.DOMAIN_REJECTED, - message=STALE_RUNNING_MESSAGE, - data={ - "reason_code": STALE_RUNNING_REASON, - "remote_observation": "UNKNOWN", - }, - ) + error = stale_running_error() failed = self._operations.fail( operation.operation_id, error.model_dump(mode="json"), diff --git a/src/thoth/protocol/operation_claim.py b/src/thoth/protocol/operation_claim.py index 8180e30..010bca2 100644 --- a/src/thoth/protocol/operation_claim.py +++ b/src/thoth/protocol/operation_claim.py @@ -5,6 +5,7 @@ from thoth.domain.operation import OperationRecord from thoth.ports.journal import JournalPort from thoth.ports.operation import OperationStorePort +from thoth.protocol.jsonrpc import JsonRpcError, JsonRpcResponse, RpcErrorCode def claim_operation( @@ -13,3 +14,18 @@ def claim_operation( if journal is None: return operations.claim(candidate) return operations.claim(candidate, started_event=partial(journal.prepare_started, candidate)) + + +def claim_busy_response(request_id: str | int) -> JsonRpcResponse: + return JsonRpcResponse( + id=request_id, + error=JsonRpcError( + code=RpcErrorCode.DOMAIN_REJECTED, + message="operation storage is busy; retry the same request later", + data={ + "reason_code": "OPERATION_CLAIM_BUSY", + "operation_claimed": False, + "retryable": True, + }, + ), + ) diff --git a/src/thoth/protocol/operation_owner.py b/src/thoth/protocol/operation_owner.py new file mode 100644 index 0000000..4a05ccc --- /dev/null +++ b/src/thoth/protocol/operation_owner.py @@ -0,0 +1,217 @@ +"""LOCAL recovery for measured commands whose original worker is confirmed gone.""" + +from collections.abc import Callable +from contextlib import AbstractContextManager + +from thoth.domain.deployment_mode import STALE_RUNNING_MESSAGE, STALE_RUNNING_REASON +from thoth.domain.enums import OperationState +from thoth.domain.operation import OperationOwnerRecord, OperationRecord +from thoth.domain.resource_scope import current_resource_uses, resource_use_scope +from thoth.ports.journal import JournalPort +from thoth.ports.operation import OperationStorePort +from thoth.ports.resource_scope import ResourceAccessPort +from thoth.ports.runtime import AtomicUnitOfWorkPort, ClockPort +from thoth.ports.worker_identity import WorkerIdentityPort +from thoth.protocol.jsonrpc import JsonRpcError, JsonRpcRequest, RpcErrorCode + +# V-S2 approved evidence is pinned by tests/fixtures/sealable_methods.json. +SEALABLE_METHODS: frozenset[str] = frozenset( + { + "action/authorization/decide", + "action/plan/revise", + "action/revise", + "action/select", + "action/step/add", + "action/step/revise", + "closure/decide", + "closure/finalize", + "closure/reopen", + "criteria/field/correct", + "criteria/profile/apply", + "execution/cancel", + "execution/observation/link", + "execution/pause", + "execution/resume", + "execution/retry", + "execution/start", + "hypothesis/assumption/add", + "hypothesis/causal/update", + "hypothesis/intent/update", + "hypothesis/prediction/bind", + "hypothesis/relation/add", + "hypothesis/relation/remove", + "hypothesis/revise", + "improvement/evaluation/assess", + "improvement/exposure/complete", + "improvement/exposure/decide", + "improvement/exposure/rollback", + "improvement/exposure/start", + "improvement/promotion/decide", + "improvement/revise", + "investigation/pause", + "investigation/resume", + "investigation/stop", + "investigation/update", + "memory/candidate/classify", + "memory/settings/update", + "model/callSettings/update", + "model/settings/update", + "object/attention/acknowledge", + "object/facet/update", + "object/frame/revise", + "object/profile/apply", + "object/relation/add", + "object/relation/remove", + "outcome/assess", + "outcome/observation/link", + "outcome/reassess", + "project/activate", + "project/cutoff/update", + "project/metadata/update", + "project/overlay/update", + "project/policy/update", + "project/reference/import", + "project/role/assign", + "project/role/revoke", + "project/source/scope/assign", + "project/source/scope/grant", + "project/source/scope/revoke", + "project/source/scope/update", + "project/source/time/confirm", + "project/source/time/correct", + "revision/baseline/decide", + "revision/changeSet/validate", + "revision/merge/resolve", + "thread/metadata/update", + "trace/confirm", + } +) + + +def owner_gone_error() -> JsonRpcError: + return JsonRpcError( + code=RpcErrorCode.DOMAIN_REJECTED, + message=( + "이전에 보낸 같은 요청을 실행하던 THOTH가 끝나서 요청이 마무리되지 못했습니다. " + "변경이 저장됐는지 확인한 뒤 필요하면 새 요청으로 다시 보내세요. / " + "The THOTH process that was running this request ended before it finished. " + "Check whether the change was saved, then send it again as a new request " + "if you still need it." + ), + data={ + "reason_code": "OPERATION_OWNER_GONE", + "remote_observation": "UNKNOWN", + "new_request_required": True, + }, + ) + + +def stale_running_error() -> JsonRpcError: + return JsonRpcError( + code=RpcErrorCode.DOMAIN_REJECTED, + message=STALE_RUNNING_MESSAGE, + data={"reason_code": STALE_RUNNING_REASON, "remote_observation": "UNKNOWN"}, + ) + + +class OperationOwner: + """Injected LOCAL policy; OS identity/liveness is never read inside the UoW.""" + + def __init__( + self, + *, + worker: WorkerIdentityPort, + host_token: str | None, + holds_workspace: Callable[[], bool], + pin_workspace: Callable[[], AbstractContextManager[bool]], + operations: OperationStorePort, + uow: AtomicUnitOfWorkPort, + journal: JournalPort, + clock: ClockPort, + resource_access: ResourceAccessPort | None = None, + ) -> None: + self._worker = worker + self._host_token = host_token + self._holds_workspace = holds_workspace + self._pin_workspace = pin_workspace + self._operations = operations + self._uow = uow + self._journal = journal + self._clock = clock + self._resource_access = resource_access + self._record = ( + None + if host_token is None or worker.process_marker == "UNKNOWN" + else OperationOwnerRecord( + worker_id=worker.worker_id, + process_id=worker.process_id, + process_marker=worker.process_marker, + host_token=host_token, + ) + ) + + def record(self) -> OperationOwnerRecord | None: + return self._record + + def gone(self, operation: OperationRecord, method: str) -> bool: + owner = operation.owner + if ( + operation.state is not OperationState.RUNNING + or method != operation.method + or method not in SEALABLE_METHODS + or owner is None + or self._record is None + or owner.host_token is None + or owner.host_token != self._host_token + or owner.process_marker == "UNKNOWN" + or not self._holds_workspace() + ): + return False + return ( + self._worker.is_alive(owner.process_id, owner.process_marker, owner.worker_id) is False + ) + + def seal( + self, + request: JsonRpcRequest, + operation: OperationRecord, + measure: Callable[[JsonRpcRequest, str, str], None], + ) -> OperationRecord | None: + if request.params.meta.idempotency_key != operation.idempotency_key or not self.gone( + operation, request.method + ): + return None + with self._pin_workspace() as held: + if not held: + return None + with resource_use_scope(operation.project_id), self._uow.transaction(): + current = self._operations.read(operation.operation_id) + if current is None: + return None + if self._resource_access is not None: + self._resource_access.require_operation(current) + if current.state is not OperationState.RUNNING: + return current + if current.owner != operation.owner: + return current + error = owner_gone_error() + payload = error.model_dump(mode="json") + if self._resource_access is not None: + self._resource_access.require_operation( + current.model_copy( + update={"error": payload, "resource_uses": current_resource_uses()} + ) + ) + failed = self._operations.fail( + current.operation_id, payload, completed_at=self._clock.now() + ) + if failed.state is not OperationState.FAILED or failed.error != payload: + return failed + self._journal.append( + project_id=failed.project_id, + operation_id=failed.operation_id, + event_type="operation.failed", + payload={"code": error.code, "message": error.message}, + ) + measure(request, failed.project_id, "FAILED") + return failed diff --git a/tests/atomicity/harness.py b/tests/atomicity/harness.py index b5d591e..1f60458 100644 --- a/tests/atomicity/harness.py +++ b/tests/atomicity/harness.py @@ -12,6 +12,7 @@ from thoth.adapters.storage.resource_scope_schema import operation_resource_bindings from thoth.adapters.storage.schema import events, metadata, operations from thoth.domain.canonical import canonical_payload, domain_digest +from thoth.domain.operation import OperationOwnerRecord from thoth.domain.resource_scope import OperationResourceBinding from thoth.protocol.jsonrpc import JsonRpcRequest @@ -60,6 +61,7 @@ def matches(self, table: str, direction: str, row: dict[str, Any]) -> bool: def assert_phase_delta( before: Snapshot, after: Snapshot, allowed: tuple[AllowedRow, ...] = () ) -> None: + allowed = (*allowed, *operation_owner_allowances(before, after, allowed)) unexpected: list[tuple[str, str, str]] = [] for table in sorted(before.keys() | after.keys()): left, right = Counter(before.get(table, ())), Counter(after.get(table, ())) @@ -71,6 +73,66 @@ def assert_phase_delta( assert not unexpected, unexpected +def operation_owner_allowances( + before: Snapshot, after: Snapshot, allowed: tuple[AllowedRow, ...] +) -> tuple[AllowedRow, ...]: + """At most one owner added for each explicitly allowed, newly created operation.""" + old_ids = {json.loads(row)["operation_id"] for row in before.get("operations", ())} + old_owner_ids = {json.loads(row)["operation_id"] for row in before.get("operation_owners", ())} + new_operations = Counter(after.get("operations", ())) - Counter(before.get("operations", ())) + owners = [json.loads(row) for row in after.get("operation_owners", ())] + result: list[AllowedRow] = [] + for raw in new_operations: + operation = json.loads(raw) + identifier = operation["operation_id"] + if identifier in old_ids or identifier in old_owner_ids: + continue + if not any( + rule.identity.get("operation_id") == identifier + and rule.matches("operations", "added", operation) + for rule in allowed + ): + continue + rows = [row for row in owners if row["operation_id"] == identifier] + assert len(rows) <= 1, "duplicate owner rows for one operation" + if rows: + row = rows[0] + owner = OperationOwnerRecord.model_validate( + {key: value for key, value in row.items() if key != "operation_id"} + ) + assert owner.host_token is not None + result.append(AllowedRow("operation_owners", row, "added", {})) + return tuple(result) + + +def unchanged_or_operation_owner( + before: Snapshot, after: Snapshot, table: str, command: JsonRpcRequest +) -> bool: + """Keep domain equality exact; validate only this new request's companion owner.""" + if before.get(table, ()) == after.get(table, ()): + return True + if table != "operation_owners": + return False + candidates = [ + json.loads(row) + for row in after.get("operations", ()) + if json.loads(row)["method"] == command.method + and json.loads(row)["idempotency_key"] == command.params.meta.idempotency_key + and json.loads(row)["project_id"] == command.params.input["project_id"] + ] + assert len(candidates) == 1 + operation = candidates[0] + allowed = operation_owner_allowances( + before, after, (AllowedRow("operations", operation, "added", {}),) + ) + left, right = Counter(before.get(table, ())), Counter(after.get(table, ())) + assert not left - right, "owner removal or replacement is not bookkeeping" + added = list((right - left).elements()) + assert len(added) == 1 and len(allowed) == 1, "expected exactly this request's owner row" + assert allowed[0].matches(table, "added", json.loads(added[0])) + return True + + def failed_command_allowances( engine: Engine, command: JsonRpcRequest, *, expected_reads: tuple[str, ...] = () ) -> tuple[AllowedRow, ...]: diff --git a/tests/fixtures/sealable_methods.json b/tests/fixtures/sealable_methods.json new file mode 100644 index 0000000..24b8de3 --- /dev/null +++ b/tests/fixtures/sealable_methods.json @@ -0,0 +1,78 @@ +{ + "schema_version": "1.0.0", + "methods": [ + "action/authorization/decide", + "action/plan/revise", + "action/revise", + "action/select", + "action/step/add", + "action/step/revise", + "closure/decide", + "closure/finalize", + "closure/reopen", + "criteria/field/correct", + "criteria/profile/apply", + "execution/cancel", + "execution/observation/link", + "execution/pause", + "execution/resume", + "execution/retry", + "execution/start", + "hypothesis/assumption/add", + "hypothesis/causal/update", + "hypothesis/intent/update", + "hypothesis/prediction/bind", + "hypothesis/relation/add", + "hypothesis/relation/remove", + "hypothesis/revise", + "improvement/evaluation/assess", + "improvement/exposure/complete", + "improvement/exposure/decide", + "improvement/exposure/rollback", + "improvement/exposure/start", + "improvement/promotion/decide", + "improvement/revise", + "investigation/pause", + "investigation/resume", + "investigation/stop", + "investigation/update", + "memory/candidate/classify", + "memory/settings/update", + "model/callSettings/update", + "model/settings/update", + "object/attention/acknowledge", + "object/facet/update", + "object/frame/revise", + "object/profile/apply", + "object/relation/add", + "object/relation/remove", + "outcome/assess", + "outcome/observation/link", + "outcome/reassess", + "project/activate", + "project/cutoff/update", + "project/metadata/update", + "project/overlay/update", + "project/policy/update", + "project/reference/import", + "project/role/assign", + "project/role/revoke", + "project/source/scope/assign", + "project/source/scope/grant", + "project/source/scope/revoke", + "project/source/scope/update", + "project/source/time/confirm", + "project/source/time/correct", + "revision/baseline/decide", + "revision/changeSet/validate", + "revision/merge/resolve", + "thread/metadata/update", + "trace/confirm" + ], + "evidence": { + "source_head": "96a111a3294b73478aa3aed8233be5614183c191", + "path": "outputs/refactor-vs2-20261019/sealable-evidence.json", + "sha256": "02a6dd31f253b1c243d636821c372a72555231f09d20f5c4d1bd4f12a46f57a6", + "approved_methods_sha256": "83993c93cbe89314247caa791d292cbc806a73d0c955d424d8fcf48ebeb18da4" + } +} diff --git a/tests/fixtures/storage_migration_ddl.json b/tests/fixtures/storage_migration_ddl.json index 1f64ae6..b2cfd0c 100644 --- a/tests/fixtures/storage_migration_ddl.json +++ b/tests/fixtures/storage_migration_ddl.json @@ -1,7 +1,7 @@ { "contract": { "heads": [ - "3b06e4f87d92" + "4c17f5089ea3" ], "schema_rows": [ [ @@ -1726,6 +1726,12 @@ "object_relations", null ], + [ + "index", + "sqlite_autoindex_operation_owners_1", + "operation_owners", + null + ], [ "index", "sqlite_autoindex_operation_resource_bindings_1", @@ -2524,6 +2530,12 @@ "object_relations", "CREATE TABLE object_relations (\n\trelation_revision_id VARCHAR(160) NOT NULL, \n\trelation_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tsource_object_id VARCHAR(160) NOT NULL, \n\trevision_digest VARCHAR(160) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\tcreated_at VARCHAR(160) NOT NULL, \n\tPRIMARY KEY (relation_revision_id)\n)" ], + [ + "table", + "operation_owners", + "operation_owners", + "CREATE TABLE operation_owners (\n\toperation_id VARCHAR(160) NOT NULL, \n\tworker_id VARCHAR(160) NOT NULL, \n\tprocess_id INTEGER NOT NULL, \n\tprocess_marker VARCHAR(160) NOT NULL, \n\thost_token VARCHAR(160) NOT NULL, \n\tPRIMARY KEY (operation_id)\n)" + ], [ "table", "operation_resource_bindings", diff --git a/tests/fixtures/storage_schema_contract.json b/tests/fixtures/storage_schema_contract.json index b6a2dc4..9909152 100644 --- a/tests/fixtures/storage_schema_contract.json +++ b/tests/fixtures/storage_schema_contract.json @@ -95,6 +95,8 @@ "thoth.adapters.storage.memory.memory_records": "memory_records", "thoth.adapters.storage.memory.memory_revision_ledger": "memory_revision_ledger", "thoth.adapters.storage.memory.memory_transition_receipts": "memory_transition_receipts", + "thoth.adapters.storage.operation_owner_schema.operation_owners": "operation_owners", + "thoth.adapters.storage.operation_owner_store.operation_owners": "operation_owners", "thoth.adapters.storage.operations.idempotency_keys": "idempotency_keys", "thoth.adapters.storage.operations.memory_records": "memory_records", "thoth.adapters.storage.operations.memory_revision_ledger": "memory_revision_ledger", @@ -329,6 +331,7 @@ "governance_revisions", "governance_heads", "research_identities", + "operation_owners", "resource_scope_history", "resource_scope_heads", "resource_scope_receipts", @@ -606,6 +609,7 @@ "object_candidates", "object_profiles", "object_relations", + "operation_owners", "operation_resource_bindings", "operations", "organizations", @@ -754,6 +758,7 @@ "governance_revisions", "governance_heads", "research_identities", + "operation_owners", "resource_scope_history", "resource_scope_heads", "resource_scope_receipts", @@ -2325,6 +2330,3260 @@ }, "sql": "\nCREATE TABLE object_relations (\n\trelation_revision_id VARCHAR(160) NOT NULL, \n\trelation_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tsource_object_id VARCHAR(160) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\trevision_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (relation_revision_id), \n\tUNIQUE (revision_digest)\n)\n\n" }, + "operation_owners": { + "columns": [ + "operation_id", + "worker_id", + "process_id", + "process_marker", + "host_token" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint" + ], + "indexes": {}, + "sql": "\nCREATE TABLE operation_owners (\n\toperation_id VARCHAR(160) NOT NULL, \n\tworker_id VARCHAR(160) NOT NULL, \n\tprocess_id INTEGER NOT NULL, \n\tprocess_marker VARCHAR(160) NOT NULL, \n\thost_token VARCHAR(160) NOT NULL, \n\tPRIMARY KEY (operation_id)\n)\n\n" + }, + "operation_resource_bindings": { + "columns": [ + "operation_id", + "project_id", + "binding_digest", + "content_json" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint" + ], + "indexes": {}, + "sql": "\nCREATE TABLE operation_resource_bindings (\n\toperation_id VARCHAR(200) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tbinding_digest VARCHAR(64) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\tPRIMARY KEY (operation_id)\n)\n\n" + }, + "operations": { + "columns": [ + "operation_id", + "project_id", + "method", + "idempotency_key", + "scope_digest", + "owner_actor_id", + "owner_session_id", + "owner_role_assignment_id", + "owner_data_scopes_json", + "state", + "result_json", + "error_json", + "created_at", + "completed_at", + "epoch" + ], + "constraint_names": [ + null, + "uq_operation_idempotency_scope" + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_operations_project_id": "CREATE INDEX ix_operations_project_id ON operations (project_id)" + }, + "sql": "\nCREATE TABLE operations (\n\toperation_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tmethod VARCHAR(160) NOT NULL, \n\tidempotency_key VARCHAR(260) NOT NULL, \n\tscope_digest VARCHAR(64) NOT NULL, \n\towner_actor_id VARCHAR(160), \n\towner_session_id VARCHAR(160), \n\towner_role_assignment_id VARCHAR(160), \n\towner_data_scopes_json TEXT DEFAULT '[]' NOT NULL, \n\tstate VARCHAR(40) NOT NULL, \n\tresult_json TEXT, \n\terror_json TEXT, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tcompleted_at VARCHAR(32), \n\tepoch INTEGER DEFAULT '0' NOT NULL, \n\tPRIMARY KEY (operation_id), \n\tCONSTRAINT uq_operation_idempotency_scope UNIQUE (project_id, method, idempotency_key)\n)\n\n" + }, + "organizations": { + "columns": [ + "organization_id", + "name", + "kind", + "created_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint" + ], + "indexes": {}, + "sql": "\nCREATE TABLE organizations (\n\torganization_id VARCHAR(160) NOT NULL, \n\tname TEXT NOT NULL, \n\tkind VARCHAR(80) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (organization_id)\n)\n\n" + }, + "outcome_assessments": { + "columns": [ + "assessment_revision_id", + "outcome_assessment_id", + "project_id", + "outcome_series_id", + "object_id", + "content_json", + "revision_digest", + "created_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_outcome_assessments_object_id": "CREATE INDEX ix_outcome_assessments_object_id ON outcome_assessments (object_id)", + "ix_outcome_assessments_outcome_assessment_id": "CREATE INDEX ix_outcome_assessments_outcome_assessment_id ON outcome_assessments (outcome_assessment_id)", + "ix_outcome_assessments_outcome_series_id": "CREATE INDEX ix_outcome_assessments_outcome_series_id ON outcome_assessments (outcome_series_id)", + "ix_outcome_assessments_project_id": "CREATE INDEX ix_outcome_assessments_project_id ON outcome_assessments (project_id)" + }, + "sql": "\nCREATE TABLE outcome_assessments (\n\tassessment_revision_id VARCHAR(160) NOT NULL, \n\toutcome_assessment_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\toutcome_series_id VARCHAR(160) NOT NULL, \n\tobject_id VARCHAR(160) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\trevision_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (assessment_revision_id), \n\tUNIQUE (revision_digest)\n)\n\n" + }, + "outcome_attributions": { + "columns": [ + "attribution_assessment_id", + "project_id", + "outcome_assessment_id", + "content_json", + "attribution_digest", + "created_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_outcome_attributions_outcome_assessment_id": "CREATE INDEX ix_outcome_attributions_outcome_assessment_id ON outcome_attributions (outcome_assessment_id)", + "ix_outcome_attributions_project_id": "CREATE INDEX ix_outcome_attributions_project_id ON outcome_attributions (project_id)" + }, + "sql": "\nCREATE TABLE outcome_attributions (\n\tattribution_assessment_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\toutcome_assessment_id VARCHAR(160) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\tattribution_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (attribution_assessment_id), \n\tUNIQUE (attribution_digest)\n)\n\n" + }, + "outcome_audit": { + "columns": [ + "audit_id", + "project_id", + "subject_id", + "event_type", + "payload_json", + "event_digest", + "created_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_outcome_audit_project_id": "CREATE INDEX ix_outcome_audit_project_id ON outcome_audit (project_id)", + "ix_outcome_audit_subject_id": "CREATE INDEX ix_outcome_audit_subject_id ON outcome_audit (subject_id)" + }, + "sql": "\nCREATE TABLE outcome_audit (\n\taudit_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tsubject_id VARCHAR(160) NOT NULL, \n\tevent_type VARCHAR(120) NOT NULL, \n\tpayload_json TEXT NOT NULL, \n\tevent_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (audit_id), \n\tUNIQUE (event_digest)\n)\n\n" + }, + "outcome_change_sets": { + "columns": [ + "outcome_change_set_id", + "project_id", + "outcome_assessment_id", + "content_json", + "change_set_digest", + "created_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_outcome_change_sets_outcome_assessment_id": "CREATE INDEX ix_outcome_change_sets_outcome_assessment_id ON outcome_change_sets (outcome_assessment_id)", + "ix_outcome_change_sets_project_id": "CREATE INDEX ix_outcome_change_sets_project_id ON outcome_change_sets (project_id)" + }, + "sql": "\nCREATE TABLE outcome_change_sets (\n\toutcome_change_set_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\toutcome_assessment_id VARCHAR(160) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\tchange_set_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (outcome_change_set_id), \n\tUNIQUE (change_set_digest)\n)\n\n" + }, + "outcome_impacts": { + "columns": [ + "impact_assessment_id", + "project_id", + "outcome_series_id", + "content_json", + "impact_digest", + "created_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_outcome_impacts_outcome_series_id": "CREATE INDEX ix_outcome_impacts_outcome_series_id ON outcome_impacts (outcome_series_id)", + "ix_outcome_impacts_project_id": "CREATE INDEX ix_outcome_impacts_project_id ON outcome_impacts (project_id)" + }, + "sql": "\nCREATE TABLE outcome_impacts (\n\timpact_assessment_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\toutcome_series_id VARCHAR(160) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\timpact_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (impact_assessment_id), \n\tUNIQUE (impact_digest)\n)\n\n" + }, + "outcome_profiles": { + "columns": [ + "profile_ref", + "version", + "content_json", + "enabled", + "profile_digest" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": {}, + "sql": "\nCREATE TABLE outcome_profiles (\n\tprofile_ref VARCHAR(160) NOT NULL, \n\tversion INTEGER NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\tenabled INTEGER NOT NULL, \n\tprofile_digest VARCHAR(64) NOT NULL, \n\tPRIMARY KEY (profile_ref, version), \n\tUNIQUE (profile_digest)\n)\n\n" + }, + "outcome_series": { + "columns": [ + "series_revision_id", + "outcome_series_id", + "project_id", + "object_id", + "content_json", + "revision_digest", + "created_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_outcome_series_object_id": "CREATE INDEX ix_outcome_series_object_id ON outcome_series (object_id)", + "ix_outcome_series_outcome_series_id": "CREATE INDEX ix_outcome_series_outcome_series_id ON outcome_series (outcome_series_id)", + "ix_outcome_series_project_id": "CREATE INDEX ix_outcome_series_project_id ON outcome_series (project_id)" + }, + "sql": "\nCREATE TABLE outcome_series (\n\tseries_revision_id VARCHAR(160) NOT NULL, \n\toutcome_series_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tobject_id VARCHAR(160) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\trevision_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (series_revision_id), \n\tUNIQUE (revision_digest)\n)\n\n" + }, + "plan_executions": { + "columns": [ + "execution_revision_id", + "plan_execution_id", + "project_id", + "object_id", + "plan_id", + "content_json", + "revision_digest", + "created_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_plan_executions_object_id": "CREATE INDEX ix_plan_executions_object_id ON plan_executions (object_id)", + "ix_plan_executions_plan_execution_id": "CREATE INDEX ix_plan_executions_plan_execution_id ON plan_executions (plan_execution_id)", + "ix_plan_executions_plan_id": "CREATE INDEX ix_plan_executions_plan_id ON plan_executions (plan_id)", + "ix_plan_executions_project_id": "CREATE INDEX ix_plan_executions_project_id ON plan_executions (project_id)" + }, + "sql": "\nCREATE TABLE plan_executions (\n\texecution_revision_id VARCHAR(160) NOT NULL, \n\tplan_execution_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tobject_id VARCHAR(160) NOT NULL, \n\tplan_id VARCHAR(160) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\trevision_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (execution_revision_id), \n\tUNIQUE (revision_digest)\n)\n\n" + }, + "project_head_sets": { + "columns": [ + "project_id", + "revision", + "head_set_digest", + "content_json" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint" + ], + "indexes": {}, + "sql": "\nCREATE TABLE project_head_sets (\n\tproject_id VARCHAR(160) NOT NULL, \n\trevision INTEGER NOT NULL, \n\thead_set_digest VARCHAR(64) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\tPRIMARY KEY (project_id)\n)\n\n" + }, + "project_policies": { + "columns": [ + "policy_id", + "project_id", + "version", + "payload_json", + "policy_digest", + "created_at" + ], + "constraint_names": [ + null, + "uq_project_policy_version" + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_project_policies_project_id": "CREATE INDEX ix_project_policies_project_id ON project_policies (project_id)" + }, + "sql": "\nCREATE TABLE project_policies (\n\tpolicy_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tversion INTEGER NOT NULL, \n\tpayload_json TEXT NOT NULL, \n\tpolicy_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (policy_id), \n\tCONSTRAINT uq_project_policy_version UNIQUE (project_id, version)\n)\n\n" + }, + "project_references": { + "columns": [ + "reference_id", + "project_id", + "origin_project_id", + "origin_revision", + "rights_status", + "scope", + "authority_status", + "created_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint" + ], + "indexes": { + "ix_project_references_project_id": "CREATE INDEX ix_project_references_project_id ON project_references (project_id)" + }, + "sql": "\nCREATE TABLE project_references (\n\treference_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\torigin_project_id VARCHAR(160) NOT NULL, \n\torigin_revision VARCHAR(160) NOT NULL, \n\trights_status VARCHAR(80) NOT NULL, \n\tscope VARCHAR(260) NOT NULL, \n\tauthority_status VARCHAR(80) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (reference_id)\n)\n\n" + }, + "project_roles": { + "columns": [ + "role_assignment_id", + "project_id", + "actor_id", + "organization_id", + "role", + "scope", + "authority_tags_json", + "state", + "created_at", + "revoked_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint" + ], + "indexes": { + "ix_project_roles_actor_id": "CREATE INDEX ix_project_roles_actor_id ON project_roles (actor_id)", + "ix_project_roles_project_id": "CREATE INDEX ix_project_roles_project_id ON project_roles (project_id)" + }, + "sql": "\nCREATE TABLE project_roles (\n\trole_assignment_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tactor_id VARCHAR(160) NOT NULL, \n\torganization_id VARCHAR(160), \n\trole VARCHAR(160) NOT NULL, \n\tscope VARCHAR(260) NOT NULL, \n\tauthority_tags_json TEXT NOT NULL, \n\tstate VARCHAR(40) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\trevoked_at VARCHAR(32), \n\tPRIMARY KEY (role_assignment_id)\n)\n\n" + }, + "projects": { + "columns": [ + "project_id", + "name", + "description", + "cutoff_at", + "lifecycle", + "overlay", + "policy_ref", + "created_at", + "revision" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint" + ], + "indexes": {}, + "sql": "\nCREATE TABLE projects (\n\tproject_id VARCHAR(160) NOT NULL, \n\tname TEXT NOT NULL, \n\tdescription TEXT DEFAULT '' NOT NULL, \n\tcutoff_at VARCHAR(32) NOT NULL, \n\tlifecycle VARCHAR(40) NOT NULL, \n\toverlay VARCHAR(160) DEFAULT 'default' NOT NULL, \n\tpolicy_ref VARCHAR(160) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\trevision INTEGER DEFAULT '0' NOT NULL, \n\tPRIMARY KEY (project_id)\n)\n\n" + }, + "receipt_dag_bundles": { + "columns": [ + "bundle_id", + "project_id", + "content_json", + "bundle_digest" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_receipt_dag_bundles_project_id": "CREATE INDEX ix_receipt_dag_bundles_project_id ON receipt_dag_bundles (project_id)" + }, + "sql": "\nCREATE TABLE receipt_dag_bundles (\n\tbundle_id VARCHAR(200) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\tbundle_digest VARCHAR(64) NOT NULL, \n\tPRIMARY KEY (bundle_id), \n\tUNIQUE (bundle_digest)\n)\n\n" + }, + "receipt_dag_edges": { + "columns": [ + "edge_id", + "project_id", + "parent_node_id", + "child_node_id", + "content_json", + "edge_digest" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_receipt_dag_edges_project_id": "CREATE INDEX ix_receipt_dag_edges_project_id ON receipt_dag_edges (project_id)" + }, + "sql": "\nCREATE TABLE receipt_dag_edges (\n\tedge_id VARCHAR(200) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tparent_node_id VARCHAR(200) NOT NULL, \n\tchild_node_id VARCHAR(200) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\tedge_digest VARCHAR(64) NOT NULL, \n\tPRIMARY KEY (edge_id, project_id), \n\tUNIQUE (edge_digest)\n)\n\n" + }, + "receipt_dag_manifests": { + "columns": [ + "manifest_id", + "project_id", + "content_json", + "manifest_digest" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": {}, + "sql": "\nCREATE TABLE receipt_dag_manifests (\n\tmanifest_id VARCHAR(200) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\tmanifest_digest VARCHAR(64) NOT NULL, \n\tPRIMARY KEY (manifest_id), \n\tUNIQUE (project_id)\n)\n\n" + }, + "receipt_dag_nodes": { + "columns": [ + "node_id", + "project_id", + "kind", + "content_json", + "node_digest" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_receipt_dag_nodes_kind": "CREATE INDEX ix_receipt_dag_nodes_kind ON receipt_dag_nodes (kind)", + "ix_receipt_dag_nodes_project_id": "CREATE INDEX ix_receipt_dag_nodes_project_id ON receipt_dag_nodes (project_id)" + }, + "sql": "\nCREATE TABLE receipt_dag_nodes (\n\tnode_id VARCHAR(200) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tkind VARCHAR(40) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\tnode_digest VARCHAR(64) NOT NULL, \n\tPRIMARY KEY (node_id, project_id), \n\tUNIQUE (node_digest)\n)\n\n" + }, + "receipt_dag_verifications": { + "columns": [ + "verification_id", + "project_id", + "subject_id", + "content_json", + "verification_digest" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_receipt_dag_verifications_project_id": "CREATE INDEX ix_receipt_dag_verifications_project_id ON receipt_dag_verifications (project_id)", + "ix_receipt_dag_verifications_subject_id": "CREATE INDEX ix_receipt_dag_verifications_subject_id ON receipt_dag_verifications (subject_id)" + }, + "sql": "\nCREATE TABLE receipt_dag_verifications (\n\tverification_id VARCHAR(200) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tsubject_id VARCHAR(200) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\tverification_digest VARCHAR(64) NOT NULL, \n\tPRIMARY KEY (verification_id), \n\tUNIQUE (verification_digest)\n)\n\n" + }, + "receipts": { + "columns": [ + "receipt_id", + "project_id", + "receipt_type", + "payload_json", + "receipt_digest", + "previous_transition_digest", + "created_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_receipts_project_id": "CREATE INDEX ix_receipts_project_id ON receipts (project_id)" + }, + "sql": "\nCREATE TABLE receipts (\n\treceipt_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\treceipt_type VARCHAR(40) NOT NULL, \n\tpayload_json TEXT NOT NULL, \n\treceipt_digest VARCHAR(64) NOT NULL, \n\tprevious_transition_digest VARCHAR(64), \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (receipt_id), \n\tUNIQUE (receipt_digest)\n)\n\n" + }, + "relations": { + "columns": [ + "relation_id", + "project_id", + "source_ref", + "relation_type", + "target_ref", + "payload_json", + "revision_digest" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint" + ], + "indexes": { + "ix_relations_project_id": "CREATE INDEX ix_relations_project_id ON relations (project_id)", + "ix_relations_source_ref": "CREATE INDEX ix_relations_source_ref ON relations (source_ref)" + }, + "sql": "\nCREATE TABLE relations (\n\trelation_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tsource_ref VARCHAR(260) NOT NULL, \n\trelation_type VARCHAR(80) NOT NULL, \n\ttarget_ref VARCHAR(260) NOT NULL, \n\tpayload_json TEXT NOT NULL, \n\trevision_digest VARCHAR(64) NOT NULL, \n\tPRIMARY KEY (relation_id)\n)\n\n" + }, + "research_identities": { + "columns": [ + "project_id", + "owner_revision", + "entity_id", + "aggregate_kind", + "object_id", + "schema_family", + "snapshot_id", + "content_digest", + "content_json" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint" + ], + "indexes": { + "ix_research_identity_entity": "CREATE INDEX ix_research_identity_entity ON research_identities (entity_id, aggregate_kind)" + }, + "sql": "\nCREATE TABLE research_identities (\n\tproject_id VARCHAR(160) NOT NULL, \n\towner_revision VARCHAR(64) NOT NULL, \n\tentity_id VARCHAR(200) NOT NULL, \n\taggregate_kind VARCHAR(40) NOT NULL, \n\tobject_id VARCHAR(200), \n\tschema_family VARCHAR(80) NOT NULL, \n\tsnapshot_id VARCHAR(200) NOT NULL, \n\tcontent_digest VARCHAR(64) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\tPRIMARY KEY (project_id, owner_revision)\n)\n\n" + }, + "research_test_assessments": { + "columns": [ + "project_id", + "assessment_id", + "prediction_id", + "attempt_ref", + "assessment_digest", + "content_json" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint" + ], + "indexes": { + "uq_research_test_prediction_attempt": "CREATE UNIQUE INDEX uq_research_test_prediction_attempt ON research_test_assessments (project_id, prediction_id, attempt_ref)" + }, + "sql": "\nCREATE TABLE research_test_assessments (\n\tproject_id VARCHAR(160) NOT NULL, \n\tassessment_id VARCHAR(200) NOT NULL, \n\tprediction_id VARCHAR(200) NOT NULL, \n\tattempt_ref VARCHAR(200) NOT NULL, \n\tassessment_digest VARCHAR(64) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\tPRIMARY KEY (project_id, assessment_id)\n)\n\n" + }, + "resource_scope_heads": { + "columns": [ + "project_id", + "resource_ref", + "revision", + "record_digest" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint" + ], + "indexes": {}, + "sql": "\nCREATE TABLE resource_scope_heads (\n\tproject_id VARCHAR(160) NOT NULL, \n\tresource_ref VARCHAR(260) NOT NULL, \n\trevision INTEGER NOT NULL, \n\trecord_digest VARCHAR(64) NOT NULL, \n\tPRIMARY KEY (project_id, resource_ref)\n)\n\n" + }, + "resource_scope_history": { + "columns": [ + "project_id", + "resource_ref", + "revision", + "record_digest", + "content_json" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint" + ], + "indexes": {}, + "sql": "\nCREATE TABLE resource_scope_history (\n\tproject_id VARCHAR(160) NOT NULL, \n\tresource_ref VARCHAR(260) NOT NULL, \n\trevision INTEGER NOT NULL, \n\trecord_digest VARCHAR(64) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\tPRIMARY KEY (project_id, resource_ref, revision)\n)\n\n" + }, + "resource_scope_receipts": { + "columns": [ + "project_id", + "receipt_id", + "resource_ref", + "receipt_digest", + "content_json" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint" + ], + "indexes": {}, + "sql": "\nCREATE TABLE resource_scope_receipts (\n\tproject_id VARCHAR(160) NOT NULL, \n\treceipt_id VARCHAR(200) NOT NULL, \n\tresource_ref VARCHAR(260) NOT NULL, \n\treceipt_digest VARCHAR(64) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\tPRIMARY KEY (project_id, receipt_id)\n)\n\n" + }, + "revision_parents": { + "columns": [ + "revision_id", + "ordinal", + "parent_digest" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "ForeignKeyConstraint", + "PrimaryKeyConstraint" + ], + "indexes": {}, + "sql": "\nCREATE TABLE revision_parents (\n\trevision_id VARCHAR(160) NOT NULL, \n\tordinal INTEGER NOT NULL, \n\tparent_digest VARCHAR(64) NOT NULL, \n\tPRIMARY KEY (revision_id, ordinal), \n\tFOREIGN KEY(revision_id) REFERENCES semantic_revisions (revision_id)\n)\n\n" + }, + "semantic_revisions": { + "columns": [ + "revision_id", + "project_id", + "entity_type", + "entity_id", + "snapshot_id", + "actor_json", + "reason", + "evidence_refs_json", + "affected_refs_json", + "revision_digest", + "created_at", + "schema_version" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "ForeignKeyConstraint", + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_semantic_revisions_entity_id": "CREATE INDEX ix_semantic_revisions_entity_id ON semantic_revisions (entity_id)", + "ix_semantic_revisions_entity_type": "CREATE INDEX ix_semantic_revisions_entity_type ON semantic_revisions (entity_type)", + "ix_semantic_revisions_project_id": "CREATE INDEX ix_semantic_revisions_project_id ON semantic_revisions (project_id)" + }, + "sql": "\nCREATE TABLE semantic_revisions (\n\trevision_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tentity_type VARCHAR(60) NOT NULL, \n\tentity_id VARCHAR(160) NOT NULL, \n\tsnapshot_id VARCHAR(160) NOT NULL, \n\tactor_json TEXT NOT NULL, \n\treason TEXT NOT NULL, \n\tevidence_refs_json TEXT NOT NULL, \n\taffected_refs_json TEXT NOT NULL, \n\trevision_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tschema_version VARCHAR(40) NOT NULL, \n\tPRIMARY KEY (revision_id), \n\tFOREIGN KEY(snapshot_id) REFERENCES entity_snapshots (snapshot_id), \n\tUNIQUE (revision_digest)\n)\n\n" + }, + "source_bindings": { + "columns": [ + "binding_id", + "project_id", + "artifact_id", + "capability", + "state", + "created_at", + "updated_at" + ], + "constraint_names": [ + null, + "uq_source_binding_artifact" + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_source_bindings_artifact_id": "CREATE INDEX ix_source_bindings_artifact_id ON source_bindings (artifact_id)", + "ix_source_bindings_project_id": "CREATE INDEX ix_source_bindings_project_id ON source_bindings (project_id)" + }, + "sql": "\nCREATE TABLE source_bindings (\n\tbinding_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tartifact_id VARCHAR(160) NOT NULL, \n\tcapability VARCHAR(40) NOT NULL, \n\tstate VARCHAR(40) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tupdated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (binding_id), \n\tCONSTRAINT uq_source_binding_artifact UNIQUE (project_id, artifact_id)\n)\n\n" + }, + "span_corrections": { + "columns": [ + "correction_id", + "project_id", + "span_id", + "corrected_text", + "reason", + "correction_digest", + "created_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_span_corrections_project_id": "CREATE INDEX ix_span_corrections_project_id ON span_corrections (project_id)", + "ix_span_corrections_span_id": "CREATE INDEX ix_span_corrections_span_id ON span_corrections (span_id)" + }, + "sql": "\nCREATE TABLE span_corrections (\n\tcorrection_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tspan_id VARCHAR(160) NOT NULL, \n\tcorrected_text TEXT NOT NULL, \n\treason TEXT NOT NULL, \n\tcorrection_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (correction_id), \n\tUNIQUE (correction_digest)\n)\n\n" + }, + "step_execution_attempts": { + "columns": [ + "attempt_revision_id", + "attempt_id", + "project_id", + "plan_execution_id", + "step_id", + "content_json", + "revision_digest", + "created_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_step_execution_attempts_attempt_id": "CREATE INDEX ix_step_execution_attempts_attempt_id ON step_execution_attempts (attempt_id)", + "ix_step_execution_attempts_plan_execution_id": "CREATE INDEX ix_step_execution_attempts_plan_execution_id ON step_execution_attempts (plan_execution_id)", + "ix_step_execution_attempts_project_id": "CREATE INDEX ix_step_execution_attempts_project_id ON step_execution_attempts (project_id)", + "ix_step_execution_attempts_step_id": "CREATE INDEX ix_step_execution_attempts_step_id ON step_execution_attempts (step_id)" + }, + "sql": "\nCREATE TABLE step_execution_attempts (\n\tattempt_revision_id VARCHAR(160) NOT NULL, \n\tattempt_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tplan_execution_id VARCHAR(160) NOT NULL, \n\tstep_id VARCHAR(160) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\trevision_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (attempt_revision_id), \n\tUNIQUE (revision_digest)\n)\n\n" + }, + "structural_nodes": { + "columns": [ + "node_id", + "project_id", + "artifact_id", + "parent_id", + "kind", + "ordinal", + "text", + "locator_json", + "source_version_id", + "content_json" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint" + ], + "indexes": { + "ix_structural_nodes_artifact_id": "CREATE INDEX ix_structural_nodes_artifact_id ON structural_nodes (artifact_id)", + "ix_structural_nodes_project_id": "CREATE INDEX ix_structural_nodes_project_id ON structural_nodes (project_id)", + "ix_structural_nodes_source_version_id": "CREATE INDEX ix_structural_nodes_source_version_id ON structural_nodes (source_version_id)" + }, + "sql": "\nCREATE TABLE structural_nodes (\n\tnode_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tartifact_id VARCHAR(160) NOT NULL, \n\tparent_id VARCHAR(160), \n\tkind VARCHAR(60), \n\tordinal INTEGER, \n\ttext TEXT, \n\tlocator_json TEXT NOT NULL, \n\tsource_version_id VARCHAR(160), \n\tcontent_json TEXT, \n\tPRIMARY KEY (node_id)\n)\n\n" + }, + "thread_activities": { + "columns": [ + "activity_id", + "project_id", + "thread_id", + "cycle_id", + "event_type", + "payload_json", + "actor_id", + "activity_digest", + "created_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_thread_activities_project_id": "CREATE INDEX ix_thread_activities_project_id ON thread_activities (project_id)", + "ix_thread_activities_thread_id": "CREATE INDEX ix_thread_activities_thread_id ON thread_activities (thread_id)" + }, + "sql": "\nCREATE TABLE thread_activities (\n\tactivity_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tthread_id VARCHAR(160) NOT NULL, \n\tcycle_id VARCHAR(160) NOT NULL, \n\tevent_type VARCHAR(120) NOT NULL, \n\tpayload_json TEXT NOT NULL, \n\tactor_id VARCHAR(160) NOT NULL, \n\tactivity_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (activity_id), \n\tUNIQUE (activity_digest)\n)\n\n" + }, + "thread_checkpoints": { + "columns": [ + "checkpoint_id", + "project_id", + "thread_id", + "cycle_id", + "head_set_digest", + "payload_json", + "checkpoint_digest", + "created_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_thread_checkpoints_project_id": "CREATE INDEX ix_thread_checkpoints_project_id ON thread_checkpoints (project_id)", + "ix_thread_checkpoints_thread_id": "CREATE INDEX ix_thread_checkpoints_thread_id ON thread_checkpoints (thread_id)" + }, + "sql": "\nCREATE TABLE thread_checkpoints (\n\tcheckpoint_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tthread_id VARCHAR(160) NOT NULL, \n\tcycle_id VARCHAR(160) NOT NULL, \n\thead_set_digest VARCHAR(64) NOT NULL, \n\tpayload_json TEXT NOT NULL, \n\tcheckpoint_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (checkpoint_id), \n\tUNIQUE (checkpoint_digest)\n)\n\n" + }, + "thread_inputs": { + "columns": [ + "input_id", + "project_id", + "thread_id", + "kind", + "text", + "state", + "ordinal", + "created_at" + ], + "constraint_names": [ + null, + "uq_thread_input_ordinal" + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_thread_inputs_project_id": "CREATE INDEX ix_thread_inputs_project_id ON thread_inputs (project_id)", + "ix_thread_inputs_thread_id": "CREATE INDEX ix_thread_inputs_thread_id ON thread_inputs (thread_id)" + }, + "sql": "\nCREATE TABLE thread_inputs (\n\tinput_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tthread_id VARCHAR(160) NOT NULL, \n\tkind VARCHAR(40) NOT NULL, \n\ttext TEXT NOT NULL, \n\tstate VARCHAR(40) NOT NULL, \n\tordinal INTEGER NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (input_id), \n\tCONSTRAINT uq_thread_input_ordinal UNIQUE (thread_id, ordinal)\n)\n\n" + }, + "threads": { + "columns": [ + "thread_id", + "project_id", + "cycle_id", + "problem", + "display_name", + "scope_json", + "parent_thread_id", + "fork_origin", + "lifecycle", + "execution_state", + "current_object_ids_json", + "working_head_digest", + "revision", + "created_at", + "updated_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint" + ], + "indexes": { + "ix_threads_project_id": "CREATE INDEX ix_threads_project_id ON threads (project_id)" + }, + "sql": "\nCREATE TABLE threads (\n\tthread_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tcycle_id VARCHAR(160) NOT NULL, \n\tproblem TEXT NOT NULL, \n\tdisplay_name TEXT DEFAULT '' NOT NULL, \n\tscope_json TEXT DEFAULT '{}' NOT NULL, \n\tparent_thread_id VARCHAR(160), \n\tfork_origin VARCHAR(160), \n\tlifecycle VARCHAR(60) NOT NULL, \n\texecution_state VARCHAR(60) NOT NULL, \n\tcurrent_object_ids_json TEXT NOT NULL, \n\tworking_head_digest VARCHAR(64) NOT NULL, \n\trevision INTEGER DEFAULT '0' NOT NULL, \n\tcreated_at VARCHAR(32) DEFAULT '1970-01-01T00:00:00Z' NOT NULL, \n\tupdated_at VARCHAR(32) DEFAULT '1970-01-01T00:00:00Z' NOT NULL, \n\tPRIMARY KEY (thread_id)\n)\n\n" + }, + "tui_sessions": { + "columns": [ + "session_id", + "revision", + "content_json" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint" + ], + "indexes": {}, + "sql": "\nCREATE TABLE tui_sessions (\n\tsession_id VARCHAR(200) NOT NULL, \n\trevision INTEGER NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\tPRIMARY KEY (session_id)\n)\n\n" + }, + "working_heads": { + "columns": [ + "project_id", + "aggregate_key", + "revision_digest", + "updated_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint" + ], + "indexes": {}, + "sql": "\nCREATE TABLE working_heads (\n\tproject_id VARCHAR(160) NOT NULL, \n\taggregate_key VARCHAR(260) NOT NULL, \n\trevision_digest VARCHAR(64) NOT NULL, \n\tupdated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (project_id, aggregate_key)\n)\n\n" + }, + "workstreams": { + "columns": [ + "workstream_id", + "project_id", + "name", + "parent_workstream_id", + "owner_role_assignment_id", + "state", + "revision" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint" + ], + "indexes": { + "ix_workstreams_project_id": "CREATE INDEX ix_workstreams_project_id ON workstreams (project_id)" + }, + "sql": "\nCREATE TABLE workstreams (\n\tworkstream_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tname TEXT NOT NULL, \n\tparent_workstream_id VARCHAR(160), \n\towner_role_assignment_id VARCHAR(160), \n\tstate VARCHAR(40) NOT NULL, \n\trevision INTEGER DEFAULT '0' NOT NULL, \n\tPRIMARY KEY (workstream_id)\n)\n\n" + } + } + }, + "thoth.adapters.storage.evaluation_run_schema": { + "consumer_aliases": { + "thoth.adapters.storage.acquisition.artifact_versions": "artifact_versions", + "thoth.adapters.storage.acquisition.artifacts": "artifacts", + "thoth.adapters.storage.acquisition.control_records": "control_records", + "thoth.adapters.storage.acquisition.evidence_audit": "evidence_audit", + "thoth.adapters.storage.acquisition.evidence_sources": "evidence_sources", + "thoth.adapters.storage.acquisition.evidence_spans": "evidence_spans", + "thoth.adapters.storage.acquisition.project_policies": "project_policies", + "thoth.adapters.storage.acquisition.projects": "projects", + "thoth.adapters.storage.acquisition.source_bindings": "source_bindings", + "thoth.adapters.storage.acquisition.structural_nodes": "structural_nodes", + "thoth.adapters.storage.acquisition_trace.acquisition_search_intents": "acquisition_search_intents", + "thoth.adapters.storage.acquisition_trace.evidence_audit": "evidence_audit", + "thoth.adapters.storage.acquisition_trace.evidence_leads": "evidence_leads", + "thoth.adapters.storage.acquisition_trace.evidence_links": "evidence_links", + "thoth.adapters.storage.acquisition_trace.evidence_observations": "evidence_observations", + "thoth.adapters.storage.action.action_audit": "action_audit", + "thoth.adapters.storage.action.action_authorizations": "action_authorizations", + "thoth.adapters.storage.action.action_plans": "action_plans", + "thoth.adapters.storage.action.action_portfolios": "action_portfolios", + "thoth.adapters.storage.action.action_records": "action_records", + "thoth.adapters.storage.artifacts.artifact_versions": "artifact_versions", + "thoth.adapters.storage.artifacts.artifacts": "artifacts", + "thoth.adapters.storage.artifacts.evidence_spans": "evidence_spans", + "thoth.adapters.storage.artifacts.structural_nodes": "structural_nodes", + "thoth.adapters.storage.auth.auth_sessions": "auth_sessions", + "thoth.adapters.storage.baseline.baseline_candidates": "baseline_candidates", + "thoth.adapters.storage.baseline.baseline_decisions": "baseline_decisions", + "thoth.adapters.storage.baseline.baseline_sets": "baseline_sets", + "thoth.adapters.storage.baseline.project_head_sets": "project_head_sets", + "thoth.adapters.storage.behavior_artifact.behavior_artifacts": "behavior_artifacts", + "thoth.adapters.storage.behavior_artifact.behavior_registry": "behavior_registry", + "thoth.adapters.storage.behavior_execution_schema.behavior_baseline_history": "behavior_baseline_history", + "thoth.adapters.storage.behavior_execution_schema.behavior_exposure_history": "behavior_exposure_history", + "thoth.adapters.storage.control_record.control_records": "control_records", + "thoth.adapters.storage.conversation.tui_sessions": "tui_sessions", + "thoth.adapters.storage.criterion_contract.criterion_audit": "criterion_audit", + "thoth.adapters.storage.criterion_contract.criterion_conflicts": "criterion_conflicts", + "thoth.adapters.storage.criterion_contract.criterion_contracts": "criterion_contracts", + "thoth.adapters.storage.criterion_contract.criterion_profiles": "criterion_profiles", + "thoth.adapters.storage.criterion_contract.criterion_references": "criterion_references", + "thoth.adapters.storage.decision_object.decision_objects": "decision_objects", + "thoth.adapters.storage.decision_object.object_attention": "object_attention", + "thoth.adapters.storage.decision_object.object_audit": "object_audit", + "thoth.adapters.storage.decision_object.object_candidates": "object_candidates", + "thoth.adapters.storage.decision_object.object_profiles": "object_profiles", + "thoth.adapters.storage.decision_object.object_relations": "object_relations", + "thoth.adapters.storage.dependency.dependency_states": "dependency_states", + "thoth.adapters.storage.dependency.relations": "relations", + "thoth.adapters.storage.evaluation_run_schema.evaluation_runs": "evaluation_runs", + "thoth.adapters.storage.events.checkpoints": "checkpoints", + "thoth.adapters.storage.events.events": "events", + "thoth.adapters.storage.evidence_graph.evidence_audit": "evidence_audit", + "thoth.adapters.storage.evidence_graph.evidence_conflicts": "evidence_conflicts", + "thoth.adapters.storage.evidence_graph.evidence_links": "evidence_links", + "thoth.adapters.storage.evidence_graph.evidence_observations": "evidence_observations", + "thoth.adapters.storage.evidence_graph.evidence_sources": "evidence_sources", + "thoth.adapters.storage.evidence_graph.span_corrections": "span_corrections", + "thoth.adapters.storage.execution.execution_audit": "execution_audit", + "thoth.adapters.storage.execution.execution_effects": "execution_effects", + "thoth.adapters.storage.execution.execution_reconciliations": "execution_reconciliations", + "thoth.adapters.storage.execution.plan_executions": "plan_executions", + "thoth.adapters.storage.execution.step_execution_attempts": "step_execution_attempts", + "thoth.adapters.storage.field_measurement.field_events": "field_events", + "thoth.adapters.storage.field_measurement.field_exports": "field_exports", + "thoth.adapters.storage.field_measurement.field_protocol_seals": "field_protocol_seals", + "thoth.adapters.storage.field_measurement.field_scores": "field_scores", + "thoth.adapters.storage.field_measurement.field_session_metrics": "field_session_metrics", + "thoth.adapters.storage.field_measurement.field_sessions": "field_sessions", + "thoth.adapters.storage.governance.organizations": "organizations", + "thoth.adapters.storage.governance.project_policies": "project_policies", + "thoth.adapters.storage.governance.project_references": "project_references", + "thoth.adapters.storage.governance.project_roles": "project_roles", + "thoth.adapters.storage.governance.source_bindings": "source_bindings", + "thoth.adapters.storage.governance.workstreams": "workstreams", + "thoth.adapters.storage.governance_history.heads": "governance_heads", + "thoth.adapters.storage.governance_history.revisions": "governance_revisions", + "thoth.adapters.storage.governance_history_schema.governance_heads": "governance_heads", + "thoth.adapters.storage.governance_history_schema.governance_revisions": "governance_revisions", + "thoth.adapters.storage.hypothesis.hypothesis_appraisals": "hypothesis_appraisals", + "thoth.adapters.storage.hypothesis.hypothesis_assumptions": "hypothesis_assumptions", + "thoth.adapters.storage.hypothesis.hypothesis_audit": "hypothesis_audit", + "thoth.adapters.storage.hypothesis.hypothesis_portfolios": "hypothesis_portfolios", + "thoth.adapters.storage.hypothesis.hypothesis_predictions": "hypothesis_predictions", + "thoth.adapters.storage.hypothesis.hypothesis_records": "hypothesis_records", + "thoth.adapters.storage.hypothesis.hypothesis_relations": "hypothesis_relations", + "thoth.adapters.storage.hypothesis.hypothesis_test_bindings": "hypothesis_test_bindings", + "thoth.adapters.storage.investigation.investigation_audit": "investigation_audit", + "thoth.adapters.storage.investigation.investigations": "investigations", + "thoth.adapters.storage.memory.memory_context_packs": "memory_context_packs", + "thoth.adapters.storage.memory.memory_projections": "memory_projections", + "thoth.adapters.storage.memory.memory_records": "memory_records", + "thoth.adapters.storage.memory.memory_revision_ledger": "memory_revision_ledger", + "thoth.adapters.storage.memory.memory_transition_receipts": "memory_transition_receipts", + "thoth.adapters.storage.operation_owner_schema.operation_owners": "operation_owners", + "thoth.adapters.storage.operation_owner_store.operation_owners": "operation_owners", + "thoth.adapters.storage.operations.idempotency_keys": "idempotency_keys", + "thoth.adapters.storage.operations.memory_records": "memory_records", + "thoth.adapters.storage.operations.memory_revision_ledger": "memory_revision_ledger", + "thoth.adapters.storage.operations.memory_transition_receipts": "memory_transition_receipts", + "thoth.adapters.storage.operations.operation_resource_bindings": "operation_resource_bindings", + "thoth.adapters.storage.operations.operations": "operations", + "thoth.adapters.storage.operations.receipts": "receipts", + "thoth.adapters.storage.operations.semantic_revisions": "semantic_revisions", + "thoth.adapters.storage.operations.thread_inputs": "thread_inputs", + "thoth.adapters.storage.operations.working_heads": "working_heads", + "thoth.adapters.storage.outcome.outcome_assessments": "outcome_assessments", + "thoth.adapters.storage.outcome.outcome_attributions": "outcome_attributions", + "thoth.adapters.storage.outcome.outcome_audit": "outcome_audit", + "thoth.adapters.storage.outcome.outcome_change_sets": "outcome_change_sets", + "thoth.adapters.storage.outcome.outcome_impacts": "outcome_impacts", + "thoth.adapters.storage.outcome.outcome_profiles": "outcome_profiles", + "thoth.adapters.storage.outcome.outcome_series": "outcome_series", + "thoth.adapters.storage.projects.projects": "projects", + "thoth.adapters.storage.receipt_dag.receipt_dag_bundles": "receipt_dag_bundles", + "thoth.adapters.storage.receipt_dag.receipt_dag_edges": "receipt_dag_edges", + "thoth.adapters.storage.receipt_dag.receipt_dag_manifests": "receipt_dag_manifests", + "thoth.adapters.storage.receipt_dag.receipt_dag_nodes": "receipt_dag_nodes", + "thoth.adapters.storage.receipt_dag.receipt_dag_verifications": "receipt_dag_verifications", + "thoth.adapters.storage.research_identity.research_identities": "research_identities", + "thoth.adapters.storage.research_identity.semantic_revisions": "semantic_revisions", + "thoth.adapters.storage.research_schema.research_identities": "research_identities", + "thoth.adapters.storage.resource_scope_schema.operation_resource_bindings": "operation_resource_bindings", + "thoth.adapters.storage.resource_scope_schema.resource_scope_heads": "resource_scope_heads", + "thoth.adapters.storage.resource_scope_schema.resource_scope_history": "resource_scope_history", + "thoth.adapters.storage.resource_scope_schema.resource_scope_receipts": "resource_scope_receipts", + "thoth.adapters.storage.schema.acquisition_search_intents": "acquisition_search_intents", + "thoth.adapters.storage.schema.action_audit": "action_audit", + "thoth.adapters.storage.schema.action_authorizations": "action_authorizations", + "thoth.adapters.storage.schema.action_plans": "action_plans", + "thoth.adapters.storage.schema.action_portfolios": "action_portfolios", + "thoth.adapters.storage.schema.action_records": "action_records", + "thoth.adapters.storage.schema.artifact_versions": "artifact_versions", + "thoth.adapters.storage.schema.artifacts": "artifacts", + "thoth.adapters.storage.schema.auth_sessions": "auth_sessions", + "thoth.adapters.storage.schema.baseline_candidates": "baseline_candidates", + "thoth.adapters.storage.schema.baseline_decisions": "baseline_decisions", + "thoth.adapters.storage.schema.baseline_sets": "baseline_sets", + "thoth.adapters.storage.schema.behavior_artifacts": "behavior_artifacts", + "thoth.adapters.storage.schema.behavior_registry": "behavior_registry", + "thoth.adapters.storage.schema.checkpoints": "checkpoints", + "thoth.adapters.storage.schema.control_records": "control_records", + "thoth.adapters.storage.schema.criterion_audit": "criterion_audit", + "thoth.adapters.storage.schema.criterion_conflicts": "criterion_conflicts", + "thoth.adapters.storage.schema.criterion_contracts": "criterion_contracts", + "thoth.adapters.storage.schema.criterion_profiles": "criterion_profiles", + "thoth.adapters.storage.schema.criterion_references": "criterion_references", + "thoth.adapters.storage.schema.decision_objects": "decision_objects", + "thoth.adapters.storage.schema.dependency_states": "dependency_states", + "thoth.adapters.storage.schema.entity_snapshots": "entity_snapshots", + "thoth.adapters.storage.schema.events": "events", + "thoth.adapters.storage.schema.evidence_audit": "evidence_audit", + "thoth.adapters.storage.schema.evidence_conflicts": "evidence_conflicts", + "thoth.adapters.storage.schema.evidence_leads": "evidence_leads", + "thoth.adapters.storage.schema.evidence_links": "evidence_links", + "thoth.adapters.storage.schema.evidence_observations": "evidence_observations", + "thoth.adapters.storage.schema.evidence_sources": "evidence_sources", + "thoth.adapters.storage.schema.evidence_spans": "evidence_spans", + "thoth.adapters.storage.schema.execution_audit": "execution_audit", + "thoth.adapters.storage.schema.execution_effects": "execution_effects", + "thoth.adapters.storage.schema.execution_reconciliations": "execution_reconciliations", + "thoth.adapters.storage.schema.field_events": "field_events", + "thoth.adapters.storage.schema.field_exports": "field_exports", + "thoth.adapters.storage.schema.field_protocol_seals": "field_protocol_seals", + "thoth.adapters.storage.schema.field_scores": "field_scores", + "thoth.adapters.storage.schema.field_session_metrics": "field_session_metrics", + "thoth.adapters.storage.schema.field_sessions": "field_sessions", + "thoth.adapters.storage.schema.hypothesis_appraisals": "hypothesis_appraisals", + "thoth.adapters.storage.schema.hypothesis_assumptions": "hypothesis_assumptions", + "thoth.adapters.storage.schema.hypothesis_audit": "hypothesis_audit", + "thoth.adapters.storage.schema.hypothesis_portfolios": "hypothesis_portfolios", + "thoth.adapters.storage.schema.hypothesis_predictions": "hypothesis_predictions", + "thoth.adapters.storage.schema.hypothesis_records": "hypothesis_records", + "thoth.adapters.storage.schema.hypothesis_relations": "hypothesis_relations", + "thoth.adapters.storage.schema.hypothesis_test_bindings": "hypothesis_test_bindings", + "thoth.adapters.storage.schema.idempotency_keys": "idempotency_keys", + "thoth.adapters.storage.schema.investigation_audit": "investigation_audit", + "thoth.adapters.storage.schema.investigations": "investigations", + "thoth.adapters.storage.schema.memory_context_packs": "memory_context_packs", + "thoth.adapters.storage.schema.memory_projections": "memory_projections", + "thoth.adapters.storage.schema.memory_records": "memory_records", + "thoth.adapters.storage.schema.memory_revision_ledger": "memory_revision_ledger", + "thoth.adapters.storage.schema.memory_transition_receipts": "memory_transition_receipts", + "thoth.adapters.storage.schema.object_attention": "object_attention", + "thoth.adapters.storage.schema.object_audit": "object_audit", + "thoth.adapters.storage.schema.object_candidates": "object_candidates", + "thoth.adapters.storage.schema.object_profiles": "object_profiles", + "thoth.adapters.storage.schema.object_relations": "object_relations", + "thoth.adapters.storage.schema.operations": "operations", + "thoth.adapters.storage.schema.organizations": "organizations", + "thoth.adapters.storage.schema.outcome_assessments": "outcome_assessments", + "thoth.adapters.storage.schema.outcome_attributions": "outcome_attributions", + "thoth.adapters.storage.schema.outcome_audit": "outcome_audit", + "thoth.adapters.storage.schema.outcome_change_sets": "outcome_change_sets", + "thoth.adapters.storage.schema.outcome_impacts": "outcome_impacts", + "thoth.adapters.storage.schema.outcome_profiles": "outcome_profiles", + "thoth.adapters.storage.schema.outcome_series": "outcome_series", + "thoth.adapters.storage.schema.plan_executions": "plan_executions", + "thoth.adapters.storage.schema.project_head_sets": "project_head_sets", + "thoth.adapters.storage.schema.project_policies": "project_policies", + "thoth.adapters.storage.schema.project_references": "project_references", + "thoth.adapters.storage.schema.project_roles": "project_roles", + "thoth.adapters.storage.schema.projects": "projects", + "thoth.adapters.storage.schema.receipt_dag_bundles": "receipt_dag_bundles", + "thoth.adapters.storage.schema.receipt_dag_edges": "receipt_dag_edges", + "thoth.adapters.storage.schema.receipt_dag_manifests": "receipt_dag_manifests", + "thoth.adapters.storage.schema.receipt_dag_nodes": "receipt_dag_nodes", + "thoth.adapters.storage.schema.receipt_dag_verifications": "receipt_dag_verifications", + "thoth.adapters.storage.schema.receipts": "receipts", + "thoth.adapters.storage.schema.relations": "relations", + "thoth.adapters.storage.schema.revision_parents": "revision_parents", + "thoth.adapters.storage.schema.semantic_revisions": "semantic_revisions", + "thoth.adapters.storage.schema.source_bindings": "source_bindings", + "thoth.adapters.storage.schema.span_corrections": "span_corrections", + "thoth.adapters.storage.schema.step_execution_attempts": "step_execution_attempts", + "thoth.adapters.storage.schema.structural_nodes": "structural_nodes", + "thoth.adapters.storage.schema.thread_activities": "thread_activities", + "thoth.adapters.storage.schema.thread_checkpoints": "thread_checkpoints", + "thoth.adapters.storage.schema.thread_inputs": "thread_inputs", + "thoth.adapters.storage.schema.threads": "threads", + "thoth.adapters.storage.schema.tui_sessions": "tui_sessions", + "thoth.adapters.storage.schema.working_heads": "working_heads", + "thoth.adapters.storage.schema.workstreams": "workstreams", + "thoth.adapters.storage.sqlite.dependency_states": "dependency_states", + "thoth.adapters.storage.sqlite.entity_snapshots": "entity_snapshots", + "thoth.adapters.storage.sqlite.receipts": "receipts", + "thoth.adapters.storage.sqlite.revision_parents": "revision_parents", + "thoth.adapters.storage.sqlite.semantic_revisions": "semantic_revisions", + "thoth.adapters.storage.sqlite.structural_nodes": "structural_nodes", + "thoth.adapters.storage.sqlite.working_heads": "working_heads", + "thoth.adapters.storage.test_validity_schema.research_test_assessments": "research_test_assessments", + "thoth.adapters.storage.thread_runtime.thread_activities": "thread_activities", + "thoth.adapters.storage.thread_runtime.thread_checkpoints": "thread_checkpoints", + "thoth.adapters.storage.thread_runtime.thread_inputs": "thread_inputs", + "thoth.adapters.storage.threads.threads": "threads" + }, + "create_table_order": [ + "projects", + "organizations", + "project_roles", + "auth_sessions", + "field_protocol_seals", + "field_sessions", + "field_events", + "field_session_metrics", + "field_scores", + "field_exports", + "project_head_sets", + "baseline_candidates", + "baseline_sets", + "baseline_decisions", + "behavior_artifacts", + "behavior_registry", + "tui_sessions", + "workstreams", + "project_policies", + "project_references", + "source_bindings", + "threads", + "thread_activities", + "thread_checkpoints", + "thread_inputs", + "investigations", + "investigation_audit", + "acquisition_search_intents", + "evidence_leads", + "evidence_sources", + "evidence_observations", + "evidence_links", + "evidence_conflicts", + "evidence_audit", + "span_corrections", + "criterion_profiles", + "criterion_contracts", + "criterion_references", + "criterion_conflicts", + "criterion_audit", + "object_profiles", + "object_candidates", + "decision_objects", + "object_relations", + "object_attention", + "object_audit", + "hypothesis_records", + "hypothesis_portfolios", + "hypothesis_relations", + "hypothesis_assumptions", + "hypothesis_predictions", + "hypothesis_test_bindings", + "hypothesis_appraisals", + "hypothesis_audit", + "action_records", + "action_portfolios", + "action_plans", + "action_authorizations", + "action_audit", + "plan_executions", + "step_execution_attempts", + "execution_effects", + "execution_reconciliations", + "execution_audit", + "outcome_profiles", + "outcome_series", + "outcome_assessments", + "outcome_attributions", + "outcome_change_sets", + "outcome_impacts", + "outcome_audit", + "control_records", + "entity_snapshots", + "working_heads", + "receipts", + "receipt_dag_nodes", + "receipt_dag_edges", + "receipt_dag_manifests", + "receipt_dag_bundles", + "receipt_dag_verifications", + "structural_nodes", + "artifacts", + "operations", + "relations", + "dependency_states", + "memory_records", + "memory_revision_ledger", + "memory_transition_receipts", + "memory_context_packs", + "memory_projections", + "governance_revisions", + "governance_heads", + "research_identities", + "operation_owners", + "resource_scope_history", + "resource_scope_heads", + "resource_scope_receipts", + "operation_resource_bindings", + "evaluation_runs", + "behavior_exposure_history", + "behavior_baseline_history", + "research_test_assessments", + "semantic_revisions", + "artifact_versions", + "events", + "checkpoints", + "idempotency_keys", + "revision_parents", + "evidence_spans" + ], + "root_export_order": [ + "projects", + "organizations", + "project_roles", + "auth_sessions", + "field_protocol_seals", + "field_sessions", + "field_events", + "field_session_metrics", + "field_scores", + "field_exports", + "project_head_sets", + "baseline_candidates", + "baseline_sets", + "baseline_decisions", + "behavior_artifacts", + "behavior_registry", + "tui_sessions", + "workstreams", + "project_policies", + "project_references", + "source_bindings", + "threads", + "thread_activities", + "thread_checkpoints", + "thread_inputs", + "investigations", + "investigation_audit", + "acquisition_search_intents", + "evidence_leads", + "evidence_sources", + "evidence_observations", + "evidence_links", + "evidence_conflicts", + "evidence_audit", + "span_corrections", + "criterion_profiles", + "criterion_contracts", + "criterion_references", + "criterion_conflicts", + "criterion_audit", + "object_profiles", + "object_candidates", + "decision_objects", + "object_relations", + "object_attention", + "object_audit", + "hypothesis_records", + "hypothesis_portfolios", + "hypothesis_relations", + "hypothesis_assumptions", + "hypothesis_predictions", + "hypothesis_test_bindings", + "hypothesis_appraisals", + "hypothesis_audit", + "action_records", + "action_portfolios", + "action_plans", + "action_authorizations", + "action_audit", + "plan_executions", + "step_execution_attempts", + "execution_effects", + "execution_reconciliations", + "execution_audit", + "outcome_profiles", + "outcome_series", + "outcome_assessments", + "outcome_attributions", + "outcome_change_sets", + "outcome_impacts", + "outcome_audit", + "control_records", + "entity_snapshots", + "semantic_revisions", + "revision_parents", + "working_heads", + "receipts", + "receipt_dag_nodes", + "receipt_dag_edges", + "receipt_dag_manifests", + "receipt_dag_bundles", + "receipt_dag_verifications", + "structural_nodes", + "artifacts", + "artifact_versions", + "evidence_spans", + "operations", + "events", + "checkpoints", + "idempotency_keys", + "relations", + "dependency_states", + "memory_records", + "memory_revision_ledger", + "memory_transition_receipts", + "memory_context_packs", + "memory_projections" + ], + "root_exports": { + "acquisition_search_intents": "acquisition_search_intents", + "action_audit": "action_audit", + "action_authorizations": "action_authorizations", + "action_plans": "action_plans", + "action_portfolios": "action_portfolios", + "action_records": "action_records", + "artifact_versions": "artifact_versions", + "artifacts": "artifacts", + "auth_sessions": "auth_sessions", + "baseline_candidates": "baseline_candidates", + "baseline_decisions": "baseline_decisions", + "baseline_sets": "baseline_sets", + "behavior_artifacts": "behavior_artifacts", + "behavior_registry": "behavior_registry", + "checkpoints": "checkpoints", + "control_records": "control_records", + "criterion_audit": "criterion_audit", + "criterion_conflicts": "criterion_conflicts", + "criterion_contracts": "criterion_contracts", + "criterion_profiles": "criterion_profiles", + "criterion_references": "criterion_references", + "decision_objects": "decision_objects", + "dependency_states": "dependency_states", + "entity_snapshots": "entity_snapshots", + "events": "events", + "evidence_audit": "evidence_audit", + "evidence_conflicts": "evidence_conflicts", + "evidence_leads": "evidence_leads", + "evidence_links": "evidence_links", + "evidence_observations": "evidence_observations", + "evidence_sources": "evidence_sources", + "evidence_spans": "evidence_spans", + "execution_audit": "execution_audit", + "execution_effects": "execution_effects", + "execution_reconciliations": "execution_reconciliations", + "field_events": "field_events", + "field_exports": "field_exports", + "field_protocol_seals": "field_protocol_seals", + "field_scores": "field_scores", + "field_session_metrics": "field_session_metrics", + "field_sessions": "field_sessions", + "hypothesis_appraisals": "hypothesis_appraisals", + "hypothesis_assumptions": "hypothesis_assumptions", + "hypothesis_audit": "hypothesis_audit", + "hypothesis_portfolios": "hypothesis_portfolios", + "hypothesis_predictions": "hypothesis_predictions", + "hypothesis_records": "hypothesis_records", + "hypothesis_relations": "hypothesis_relations", + "hypothesis_test_bindings": "hypothesis_test_bindings", + "idempotency_keys": "idempotency_keys", + "investigation_audit": "investigation_audit", + "investigations": "investigations", + "memory_context_packs": "memory_context_packs", + "memory_projections": "memory_projections", + "memory_records": "memory_records", + "memory_revision_ledger": "memory_revision_ledger", + "memory_transition_receipts": "memory_transition_receipts", + "object_attention": "object_attention", + "object_audit": "object_audit", + "object_candidates": "object_candidates", + "object_profiles": "object_profiles", + "object_relations": "object_relations", + "operations": "operations", + "organizations": "organizations", + "outcome_assessments": "outcome_assessments", + "outcome_attributions": "outcome_attributions", + "outcome_audit": "outcome_audit", + "outcome_change_sets": "outcome_change_sets", + "outcome_impacts": "outcome_impacts", + "outcome_profiles": "outcome_profiles", + "outcome_series": "outcome_series", + "plan_executions": "plan_executions", + "project_head_sets": "project_head_sets", + "project_policies": "project_policies", + "project_references": "project_references", + "project_roles": "project_roles", + "projects": "projects", + "receipt_dag_bundles": "receipt_dag_bundles", + "receipt_dag_edges": "receipt_dag_edges", + "receipt_dag_manifests": "receipt_dag_manifests", + "receipt_dag_nodes": "receipt_dag_nodes", + "receipt_dag_verifications": "receipt_dag_verifications", + "receipts": "receipts", + "relations": "relations", + "revision_parents": "revision_parents", + "semantic_revisions": "semantic_revisions", + "source_bindings": "source_bindings", + "span_corrections": "span_corrections", + "step_execution_attempts": "step_execution_attempts", + "structural_nodes": "structural_nodes", + "thread_activities": "thread_activities", + "thread_checkpoints": "thread_checkpoints", + "thread_inputs": "thread_inputs", + "threads": "threads", + "tui_sessions": "tui_sessions", + "working_heads": "working_heads", + "workstreams": "workstreams" + }, + "sorted_tables": [ + "acquisition_search_intents", + "action_audit", + "action_authorizations", + "action_plans", + "action_portfolios", + "action_records", + "artifacts", + "auth_sessions", + "baseline_candidates", + "baseline_decisions", + "baseline_sets", + "behavior_artifacts", + "behavior_baseline_history", + "behavior_exposure_history", + "behavior_registry", + "control_records", + "criterion_audit", + "criterion_conflicts", + "criterion_contracts", + "criterion_profiles", + "criterion_references", + "decision_objects", + "dependency_states", + "entity_snapshots", + "evaluation_runs", + "evidence_audit", + "evidence_conflicts", + "evidence_leads", + "evidence_links", + "evidence_observations", + "evidence_sources", + "execution_audit", + "execution_effects", + "execution_reconciliations", + "field_events", + "field_exports", + "field_protocol_seals", + "field_scores", + "field_session_metrics", + "field_sessions", + "governance_heads", + "governance_revisions", + "hypothesis_appraisals", + "hypothesis_assumptions", + "hypothesis_audit", + "hypothesis_portfolios", + "hypothesis_predictions", + "hypothesis_records", + "hypothesis_relations", + "hypothesis_test_bindings", + "investigation_audit", + "investigations", + "memory_context_packs", + "memory_projections", + "memory_records", + "memory_revision_ledger", + "memory_transition_receipts", + "object_attention", + "object_audit", + "object_candidates", + "object_profiles", + "object_relations", + "operation_owners", + "operation_resource_bindings", + "operations", + "organizations", + "outcome_assessments", + "outcome_attributions", + "outcome_audit", + "outcome_change_sets", + "outcome_impacts", + "outcome_profiles", + "outcome_series", + "plan_executions", + "project_head_sets", + "project_policies", + "project_references", + "project_roles", + "projects", + "receipt_dag_bundles", + "receipt_dag_edges", + "receipt_dag_manifests", + "receipt_dag_nodes", + "receipt_dag_verifications", + "receipts", + "relations", + "research_identities", + "research_test_assessments", + "resource_scope_heads", + "resource_scope_history", + "resource_scope_receipts", + "source_bindings", + "span_corrections", + "step_execution_attempts", + "structural_nodes", + "thread_activities", + "thread_checkpoints", + "thread_inputs", + "threads", + "tui_sessions", + "working_heads", + "workstreams", + "artifact_versions", + "checkpoints", + "events", + "idempotency_keys", + "semantic_revisions", + "evidence_spans", + "revision_parents" + ], + "table_registration_order": [ + "projects", + "organizations", + "project_roles", + "auth_sessions", + "field_protocol_seals", + "field_sessions", + "field_events", + "field_session_metrics", + "field_scores", + "field_exports", + "project_head_sets", + "baseline_candidates", + "baseline_sets", + "baseline_decisions", + "behavior_artifacts", + "behavior_registry", + "tui_sessions", + "workstreams", + "project_policies", + "project_references", + "source_bindings", + "threads", + "thread_activities", + "thread_checkpoints", + "thread_inputs", + "investigations", + "investigation_audit", + "acquisition_search_intents", + "evidence_leads", + "evidence_sources", + "evidence_observations", + "evidence_links", + "evidence_conflicts", + "evidence_audit", + "span_corrections", + "criterion_profiles", + "criterion_contracts", + "criterion_references", + "criterion_conflicts", + "criterion_audit", + "object_profiles", + "object_candidates", + "decision_objects", + "object_relations", + "object_attention", + "object_audit", + "hypothesis_records", + "hypothesis_portfolios", + "hypothesis_relations", + "hypothesis_assumptions", + "hypothesis_predictions", + "hypothesis_test_bindings", + "hypothesis_appraisals", + "hypothesis_audit", + "action_records", + "action_portfolios", + "action_plans", + "action_authorizations", + "action_audit", + "plan_executions", + "step_execution_attempts", + "execution_effects", + "execution_reconciliations", + "execution_audit", + "outcome_profiles", + "outcome_series", + "outcome_assessments", + "outcome_attributions", + "outcome_change_sets", + "outcome_impacts", + "outcome_audit", + "control_records", + "entity_snapshots", + "semantic_revisions", + "revision_parents", + "working_heads", + "receipts", + "receipt_dag_nodes", + "receipt_dag_edges", + "receipt_dag_manifests", + "receipt_dag_bundles", + "receipt_dag_verifications", + "structural_nodes", + "artifacts", + "artifact_versions", + "evidence_spans", + "operations", + "events", + "checkpoints", + "idempotency_keys", + "relations", + "dependency_states", + "memory_records", + "memory_revision_ledger", + "memory_transition_receipts", + "memory_context_packs", + "memory_projections", + "governance_revisions", + "governance_heads", + "research_identities", + "operation_owners", + "resource_scope_history", + "resource_scope_heads", + "resource_scope_receipts", + "operation_resource_bindings", + "evaluation_runs", + "behavior_exposure_history", + "behavior_baseline_history", + "research_test_assessments" + ], + "tables": { + "acquisition_search_intents": { + "columns": [ + "search_intent_id", + "project_id", + "investigation_id", + "content_json", + "intent_digest", + "created_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_acquisition_search_intents_investigation_id": "CREATE INDEX ix_acquisition_search_intents_investigation_id ON acquisition_search_intents (investigation_id)", + "ix_acquisition_search_intents_project_id": "CREATE INDEX ix_acquisition_search_intents_project_id ON acquisition_search_intents (project_id)" + }, + "sql": "\nCREATE TABLE acquisition_search_intents (\n\tsearch_intent_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tinvestigation_id VARCHAR(160) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\tintent_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (search_intent_id), \n\tUNIQUE (intent_digest)\n)\n\n" + }, + "action_audit": { + "columns": [ + "audit_id", + "project_id", + "subject_id", + "event_type", + "payload_json", + "event_digest", + "created_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_action_audit_project_id": "CREATE INDEX ix_action_audit_project_id ON action_audit (project_id)", + "ix_action_audit_subject_id": "CREATE INDEX ix_action_audit_subject_id ON action_audit (subject_id)" + }, + "sql": "\nCREATE TABLE action_audit (\n\taudit_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tsubject_id VARCHAR(160) NOT NULL, \n\tevent_type VARCHAR(120) NOT NULL, \n\tpayload_json TEXT NOT NULL, \n\tevent_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (audit_id), \n\tUNIQUE (event_digest)\n)\n\n" + }, + "action_authorizations": { + "columns": [ + "authorization_revision_id", + "authorization_id", + "project_id", + "plan_id", + "step_id", + "content_json", + "revision_digest", + "created_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_action_authorizations_authorization_id": "CREATE INDEX ix_action_authorizations_authorization_id ON action_authorizations (authorization_id)", + "ix_action_authorizations_plan_id": "CREATE INDEX ix_action_authorizations_plan_id ON action_authorizations (plan_id)", + "ix_action_authorizations_project_id": "CREATE INDEX ix_action_authorizations_project_id ON action_authorizations (project_id)", + "ix_action_authorizations_step_id": "CREATE INDEX ix_action_authorizations_step_id ON action_authorizations (step_id)" + }, + "sql": "\nCREATE TABLE action_authorizations (\n\tauthorization_revision_id VARCHAR(160) NOT NULL, \n\tauthorization_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tplan_id VARCHAR(160) NOT NULL, \n\tstep_id VARCHAR(160) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\trevision_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (authorization_revision_id), \n\tUNIQUE (revision_digest)\n)\n\n" + }, + "action_plans": { + "columns": [ + "plan_revision_id", + "plan_id", + "project_id", + "object_id", + "content_json", + "revision_digest", + "created_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_action_plans_object_id": "CREATE INDEX ix_action_plans_object_id ON action_plans (object_id)", + "ix_action_plans_plan_id": "CREATE INDEX ix_action_plans_plan_id ON action_plans (plan_id)", + "ix_action_plans_project_id": "CREATE INDEX ix_action_plans_project_id ON action_plans (project_id)" + }, + "sql": "\nCREATE TABLE action_plans (\n\tplan_revision_id VARCHAR(160) NOT NULL, \n\tplan_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tobject_id VARCHAR(160) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\trevision_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (plan_revision_id), \n\tUNIQUE (revision_digest)\n)\n\n" + }, + "action_portfolios": { + "columns": [ + "portfolio_revision_id", + "portfolio_id", + "project_id", + "object_id", + "content_json", + "revision_digest", + "created_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_action_portfolios_object_id": "CREATE INDEX ix_action_portfolios_object_id ON action_portfolios (object_id)", + "ix_action_portfolios_portfolio_id": "CREATE INDEX ix_action_portfolios_portfolio_id ON action_portfolios (portfolio_id)", + "ix_action_portfolios_project_id": "CREATE INDEX ix_action_portfolios_project_id ON action_portfolios (project_id)" + }, + "sql": "\nCREATE TABLE action_portfolios (\n\tportfolio_revision_id VARCHAR(160) NOT NULL, \n\tportfolio_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tobject_id VARCHAR(160) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\trevision_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (portfolio_revision_id), \n\tUNIQUE (revision_digest)\n)\n\n" + }, + "action_records": { + "columns": [ + "action_revision_id", + "action_id", + "project_id", + "object_id", + "portfolio_id", + "content_json", + "revision_digest", + "created_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_action_records_action_id": "CREATE INDEX ix_action_records_action_id ON action_records (action_id)", + "ix_action_records_object_id": "CREATE INDEX ix_action_records_object_id ON action_records (object_id)", + "ix_action_records_portfolio_id": "CREATE INDEX ix_action_records_portfolio_id ON action_records (portfolio_id)", + "ix_action_records_project_id": "CREATE INDEX ix_action_records_project_id ON action_records (project_id)" + }, + "sql": "\nCREATE TABLE action_records (\n\taction_revision_id VARCHAR(160) NOT NULL, \n\taction_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tobject_id VARCHAR(160) NOT NULL, \n\tportfolio_id VARCHAR(160) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\trevision_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (action_revision_id), \n\tUNIQUE (revision_digest)\n)\n\n" + }, + "artifact_versions": { + "columns": [ + "source_version_id", + "artifact_id", + "version_label", + "byte_sha256", + "retrieved_at", + "structure_metadata_json" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "ForeignKeyConstraint", + "PrimaryKeyConstraint" + ], + "indexes": {}, + "sql": "\nCREATE TABLE artifact_versions (\n\tsource_version_id VARCHAR(160) NOT NULL, \n\tartifact_id VARCHAR(160) NOT NULL, \n\tversion_label VARCHAR(260), \n\tbyte_sha256 VARCHAR(64) NOT NULL, \n\tretrieved_at VARCHAR(32) NOT NULL, \n\tstructure_metadata_json TEXT, \n\tPRIMARY KEY (source_version_id), \n\tFOREIGN KEY(artifact_id) REFERENCES artifacts (artifact_id)\n)\n\n" + }, + "artifacts": { + "columns": [ + "artifact_id", + "project_id", + "source_uri", + "media_type", + "byte_sha256", + "authority", + "cutoff_state", + "security_class", + "parser_name", + "parser_version", + "retrieved_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint" + ], + "indexes": { + "ix_artifacts_project_id": "CREATE INDEX ix_artifacts_project_id ON artifacts (project_id)" + }, + "sql": "\nCREATE TABLE artifacts (\n\tartifact_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tsource_uri TEXT NOT NULL, \n\tmedia_type VARCHAR(260) NOT NULL, \n\tbyte_sha256 VARCHAR(64) NOT NULL, \n\tauthority VARCHAR(60) NOT NULL, \n\tcutoff_state VARCHAR(60) NOT NULL, \n\tsecurity_class VARCHAR(60) NOT NULL, \n\tparser_name VARCHAR(160) NOT NULL, \n\tparser_version VARCHAR(80) NOT NULL, \n\tretrieved_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (artifact_id)\n)\n\n" + }, + "auth_sessions": { + "columns": [ + "session_id", + "actor_id", + "project_id", + "role_assignment_id", + "token_digest", + "state", + "content_json", + "expires_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_auth_sessions_actor_id": "CREATE INDEX ix_auth_sessions_actor_id ON auth_sessions (actor_id)", + "ix_auth_sessions_project_id": "CREATE INDEX ix_auth_sessions_project_id ON auth_sessions (project_id)" + }, + "sql": "\nCREATE TABLE auth_sessions (\n\tsession_id VARCHAR(200) NOT NULL, \n\tactor_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\trole_assignment_id VARCHAR(160) NOT NULL, \n\ttoken_digest VARCHAR(64) NOT NULL, \n\tstate VARCHAR(40) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\texpires_at VARCHAR(40) NOT NULL, \n\tPRIMARY KEY (session_id), \n\tUNIQUE (token_digest)\n)\n\n" + }, + "baseline_candidates": { + "columns": [ + "candidate_id", + "project_id", + "scope", + "state", + "candidate_digest", + "content_json" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_baseline_candidates_project_id": "CREATE INDEX ix_baseline_candidates_project_id ON baseline_candidates (project_id)" + }, + "sql": "\nCREATE TABLE baseline_candidates (\n\tcandidate_id VARCHAR(200) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tscope VARCHAR(80) NOT NULL, \n\tstate VARCHAR(60) NOT NULL, \n\tcandidate_digest VARCHAR(64) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\tPRIMARY KEY (candidate_id), \n\tUNIQUE (candidate_digest)\n)\n\n" + }, + "baseline_decisions": { + "columns": [ + "decision_id", + "project_id", + "candidate_id", + "decision", + "decision_digest", + "content_json" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_baseline_decisions_project_id": "CREATE INDEX ix_baseline_decisions_project_id ON baseline_decisions (project_id)" + }, + "sql": "\nCREATE TABLE baseline_decisions (\n\tdecision_id VARCHAR(200) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tcandidate_id VARCHAR(200) NOT NULL, \n\tdecision VARCHAR(20) NOT NULL, \n\tdecision_digest VARCHAR(64) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\tPRIMARY KEY (decision_id), \n\tUNIQUE (decision_digest)\n)\n\n" + }, + "baseline_sets": { + "columns": [ + "baseline_set_id", + "project_id", + "scope", + "lifecycle", + "baseline_set_digest", + "content_json" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_baseline_sets_project_id": "CREATE INDEX ix_baseline_sets_project_id ON baseline_sets (project_id)" + }, + "sql": "\nCREATE TABLE baseline_sets (\n\tbaseline_set_id VARCHAR(200) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tscope VARCHAR(80) NOT NULL, \n\tlifecycle VARCHAR(40) NOT NULL, \n\tbaseline_set_digest VARCHAR(64) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\tPRIMARY KEY (baseline_set_id), \n\tUNIQUE (baseline_set_digest)\n)\n\n" + }, + "behavior_artifacts": { + "columns": [ + "artifact_id", + "project_id", + "kind", + "state", + "content_digest", + "content_json" + ], + "constraint_names": [ + null, + "uq_behavior_artifact_content" + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_behavior_artifacts_project_id": "CREATE INDEX ix_behavior_artifacts_project_id ON behavior_artifacts (project_id)" + }, + "sql": "\nCREATE TABLE behavior_artifacts (\n\tartifact_id VARCHAR(200) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tkind VARCHAR(80) NOT NULL, \n\tstate VARCHAR(40) NOT NULL, \n\tcontent_digest VARCHAR(64) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\tPRIMARY KEY (artifact_id), \n\tCONSTRAINT uq_behavior_artifact_content UNIQUE (project_id, kind, content_digest)\n)\n\n" + }, + "behavior_baseline_history": { + "columns": [ + "project_id", + "scope_digest", + "component", + "environment", + "revision", + "content_digest", + "receipt_digest", + "content_json" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint" + ], + "indexes": {}, + "sql": "\nCREATE TABLE behavior_baseline_history (\n\tproject_id VARCHAR(160) NOT NULL, \n\tscope_digest VARCHAR(64) NOT NULL, \n\tcomponent VARCHAR(64) NOT NULL, \n\tenvironment VARCHAR(160) NOT NULL, \n\trevision INTEGER NOT NULL, \n\tcontent_digest VARCHAR(64) NOT NULL, \n\treceipt_digest VARCHAR(64) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\tPRIMARY KEY (project_id, scope_digest, component, environment, revision)\n)\n\n" + }, + "behavior_exposure_history": { + "columns": [ + "project_id", + "exposure_id", + "revision", + "scope_digest", + "stage", + "state", + "record_digest", + "content_json" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint" + ], + "indexes": {}, + "sql": "\nCREATE TABLE behavior_exposure_history (\n\tproject_id VARCHAR(160) NOT NULL, \n\texposure_id VARCHAR(200) NOT NULL, \n\trevision INTEGER NOT NULL, \n\tscope_digest VARCHAR(64) NOT NULL, \n\tstage VARCHAR(32) NOT NULL, \n\tstate VARCHAR(32) NOT NULL, \n\trecord_digest VARCHAR(64) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\tPRIMARY KEY (project_id, exposure_id, revision)\n)\n\n" + }, + "behavior_registry": { + "columns": [ + "project_id", + "kind", + "active_digest", + "revision", + "content_json" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint" + ], + "indexes": {}, + "sql": "\nCREATE TABLE behavior_registry (\n\tproject_id VARCHAR(160) NOT NULL, \n\tkind VARCHAR(80) NOT NULL, \n\tactive_digest VARCHAR(64) NOT NULL, \n\trevision INTEGER NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\tPRIMARY KEY (project_id, kind)\n)\n\n" + }, + "checkpoints": { + "columns": [ + "checkpoint_id", + "operation_id", + "payload_json", + "checkpoint_digest", + "created_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "ForeignKeyConstraint", + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": {}, + "sql": "\nCREATE TABLE checkpoints (\n\tcheckpoint_id VARCHAR(160) NOT NULL, \n\toperation_id VARCHAR(160) NOT NULL, \n\tpayload_json TEXT NOT NULL, \n\tcheckpoint_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (checkpoint_id), \n\tFOREIGN KEY(operation_id) REFERENCES operations (operation_id), \n\tUNIQUE (checkpoint_digest)\n)\n\n" + }, + "control_records": { + "columns": [ + "control_revision_id", + "project_id", + "namespace", + "record_type", + "record_id", + "version", + "state", + "content_json", + "record_digest", + "supersedes_digest", + "created_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_control_records_namespace": "CREATE INDEX ix_control_records_namespace ON control_records (namespace)", + "ix_control_records_project_id": "CREATE INDEX ix_control_records_project_id ON control_records (project_id)", + "ix_control_records_record_id": "CREATE INDEX ix_control_records_record_id ON control_records (record_id)", + "ix_control_records_record_type": "CREATE INDEX ix_control_records_record_type ON control_records (record_type)", + "ix_control_records_state": "CREATE INDEX ix_control_records_state ON control_records (state)" + }, + "sql": "\nCREATE TABLE control_records (\n\tcontrol_revision_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tnamespace VARCHAR(80) NOT NULL, \n\trecord_type VARCHAR(120) NOT NULL, \n\trecord_id VARCHAR(160) NOT NULL, \n\tversion INTEGER NOT NULL, \n\tstate VARCHAR(80) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\trecord_digest VARCHAR(64) NOT NULL, \n\tsupersedes_digest VARCHAR(64), \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (control_revision_id), \n\tUNIQUE (record_digest)\n)\n\n" + }, + "criterion_audit": { + "columns": [ + "audit_id", + "project_id", + "criterion_id", + "event_type", + "payload_json", + "event_digest", + "created_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_criterion_audit_criterion_id": "CREATE INDEX ix_criterion_audit_criterion_id ON criterion_audit (criterion_id)", + "ix_criterion_audit_project_id": "CREATE INDEX ix_criterion_audit_project_id ON criterion_audit (project_id)" + }, + "sql": "\nCREATE TABLE criterion_audit (\n\taudit_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tcriterion_id VARCHAR(160) NOT NULL, \n\tevent_type VARCHAR(120) NOT NULL, \n\tpayload_json TEXT NOT NULL, \n\tevent_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (audit_id), \n\tUNIQUE (event_digest)\n)\n\n" + }, + "criterion_conflicts": { + "columns": [ + "conflict_id", + "project_id", + "criterion_id", + "field_path", + "candidate_values_json", + "evidence_refs_json", + "status", + "impact_json", + "conflict_digest", + "created_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_criterion_conflicts_criterion_id": "CREATE INDEX ix_criterion_conflicts_criterion_id ON criterion_conflicts (criterion_id)", + "ix_criterion_conflicts_project_id": "CREATE INDEX ix_criterion_conflicts_project_id ON criterion_conflicts (project_id)" + }, + "sql": "\nCREATE TABLE criterion_conflicts (\n\tconflict_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tcriterion_id VARCHAR(160) NOT NULL, \n\tfield_path VARCHAR(260) NOT NULL, \n\tcandidate_values_json TEXT NOT NULL, \n\tevidence_refs_json TEXT NOT NULL, \n\tstatus VARCHAR(40) NOT NULL, \n\timpact_json TEXT NOT NULL, \n\tconflict_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (conflict_id), \n\tUNIQUE (conflict_digest)\n)\n\n" + }, + "criterion_contracts": { + "columns": [ + "criterion_revision_id", + "criterion_id", + "project_id", + "thread_id", + "content_json", + "revision_digest", + "supersedes_revision_digest", + "created_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_criterion_contracts_criterion_id": "CREATE INDEX ix_criterion_contracts_criterion_id ON criterion_contracts (criterion_id)", + "ix_criterion_contracts_project_id": "CREATE INDEX ix_criterion_contracts_project_id ON criterion_contracts (project_id)" + }, + "sql": "\nCREATE TABLE criterion_contracts (\n\tcriterion_revision_id VARCHAR(160) NOT NULL, \n\tcriterion_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tthread_id VARCHAR(160), \n\tcontent_json TEXT NOT NULL, \n\trevision_digest VARCHAR(64) NOT NULL, \n\tsupersedes_revision_digest VARCHAR(64), \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (criterion_revision_id), \n\tUNIQUE (revision_digest)\n)\n\n" + }, + "criterion_profiles": { + "columns": [ + "profile_ref", + "version", + "name", + "domain_hint", + "required_fields_json", + "conditional_fields_json", + "allowed_computation_types_json", + "authority_policy_json", + "enabled", + "profile_digest", + "profile_json" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": {}, + "sql": "\nCREATE TABLE criterion_profiles (\n\tprofile_ref VARCHAR(160) NOT NULL, \n\tversion INTEGER NOT NULL, \n\tname TEXT NOT NULL, \n\tdomain_hint VARCHAR(160) NOT NULL, \n\trequired_fields_json TEXT NOT NULL, \n\tconditional_fields_json TEXT NOT NULL, \n\tallowed_computation_types_json TEXT NOT NULL, \n\tauthority_policy_json TEXT NOT NULL, \n\tenabled INTEGER NOT NULL, \n\tprofile_digest VARCHAR(64) NOT NULL, \n\tprofile_json TEXT, \n\tPRIMARY KEY (profile_ref, version), \n\tUNIQUE (profile_digest)\n)\n\n" + }, + "criterion_references": { + "columns": [ + "reference_candidate_id", + "project_id", + "criterion_id", + "content_json", + "reference_digest", + "created_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_criterion_references_criterion_id": "CREATE INDEX ix_criterion_references_criterion_id ON criterion_references (criterion_id)", + "ix_criterion_references_project_id": "CREATE INDEX ix_criterion_references_project_id ON criterion_references (project_id)" + }, + "sql": "\nCREATE TABLE criterion_references (\n\treference_candidate_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tcriterion_id VARCHAR(160) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\treference_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (reference_candidate_id), \n\tUNIQUE (reference_digest)\n)\n\n" + }, + "decision_objects": { + "columns": [ + "object_revision_id", + "object_id", + "project_id", + "thread_id", + "content_json", + "revision_digest", + "supersedes_revision_digest", + "created_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_decision_objects_object_id": "CREATE INDEX ix_decision_objects_object_id ON decision_objects (object_id)", + "ix_decision_objects_project_id": "CREATE INDEX ix_decision_objects_project_id ON decision_objects (project_id)", + "ix_decision_objects_thread_id": "CREATE INDEX ix_decision_objects_thread_id ON decision_objects (thread_id)" + }, + "sql": "\nCREATE TABLE decision_objects (\n\tobject_revision_id VARCHAR(160) NOT NULL, \n\tobject_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tthread_id VARCHAR(160) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\trevision_digest VARCHAR(64) NOT NULL, \n\tsupersedes_revision_digest VARCHAR(64), \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (object_revision_id), \n\tUNIQUE (revision_digest)\n)\n\n" + }, + "dependency_states": { + "columns": [ + "project_id", + "entity_ref", + "status", + "caused_by_revision", + "updated_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint" + ], + "indexes": {}, + "sql": "\nCREATE TABLE dependency_states (\n\tproject_id VARCHAR(160) NOT NULL, \n\tentity_ref VARCHAR(260) NOT NULL, \n\tstatus VARCHAR(60) NOT NULL, \n\tcaused_by_revision VARCHAR(64) NOT NULL, \n\tupdated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (project_id, entity_ref)\n)\n\n" + }, + "entity_snapshots": { + "columns": [ + "snapshot_id", + "project_id", + "entity_type", + "entity_id", + "schema_version", + "content_json", + "content_digest" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint" + ], + "indexes": { + "ix_entity_snapshots_entity_id": "CREATE INDEX ix_entity_snapshots_entity_id ON entity_snapshots (entity_id)", + "ix_entity_snapshots_entity_type": "CREATE INDEX ix_entity_snapshots_entity_type ON entity_snapshots (entity_type)", + "ix_entity_snapshots_project_id": "CREATE INDEX ix_entity_snapshots_project_id ON entity_snapshots (project_id)" + }, + "sql": "\nCREATE TABLE entity_snapshots (\n\tsnapshot_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tentity_type VARCHAR(60) NOT NULL, \n\tentity_id VARCHAR(160) NOT NULL, \n\tschema_version VARCHAR(40) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\tcontent_digest VARCHAR(64) NOT NULL, \n\tPRIMARY KEY (snapshot_id)\n)\n\n" + }, + "evaluation_runs": { + "columns": [ + "project_id", + "pair_id", + "dedupe_digest", + "plan_id", + "plan_digest", + "fixture_digest", + "scorer_digest", + "state", + "revision", + "record_digest", + "content_json" + ], + "constraint_names": [ + null, + "uq_evaluation_request" + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": {}, + "sql": "\nCREATE TABLE evaluation_runs (\n\tproject_id VARCHAR(160) NOT NULL, \n\tpair_id VARCHAR(200) NOT NULL, \n\tdedupe_digest VARCHAR(64) NOT NULL, \n\tplan_id VARCHAR(200) NOT NULL, \n\tplan_digest VARCHAR(64) NOT NULL, \n\tfixture_digest VARCHAR(64) NOT NULL, \n\tscorer_digest VARCHAR(64) NOT NULL, \n\tstate VARCHAR(32) NOT NULL, \n\trevision INTEGER NOT NULL, \n\trecord_digest VARCHAR(64) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\tPRIMARY KEY (project_id, pair_id), \n\tCONSTRAINT uq_evaluation_request UNIQUE (project_id, dedupe_digest)\n)\n\n" + }, + "events": { + "columns": [ + "event_id", + "project_id", + "operation_id", + "event_type", + "payload_json", + "previous_event_digest", + "event_digest", + "created_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "ForeignKeyConstraint", + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_events_project_id": "CREATE INDEX ix_events_project_id ON events (project_id)" + }, + "sql": "\nCREATE TABLE events (\n\tevent_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\toperation_id VARCHAR(160) NOT NULL, \n\tevent_type VARCHAR(160) NOT NULL, \n\tpayload_json TEXT NOT NULL, \n\tprevious_event_digest VARCHAR(64), \n\tevent_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (event_id), \n\tFOREIGN KEY(operation_id) REFERENCES operations (operation_id), \n\tUNIQUE (event_digest)\n)\n\n" + }, + "evidence_audit": { + "columns": [ + "audit_id", + "project_id", + "evidence_ref", + "event_type", + "payload_json", + "event_digest", + "created_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_evidence_audit_evidence_ref": "CREATE INDEX ix_evidence_audit_evidence_ref ON evidence_audit (evidence_ref)", + "ix_evidence_audit_project_id": "CREATE INDEX ix_evidence_audit_project_id ON evidence_audit (project_id)" + }, + "sql": "\nCREATE TABLE evidence_audit (\n\taudit_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tevidence_ref VARCHAR(160) NOT NULL, \n\tevent_type VARCHAR(120) NOT NULL, \n\tpayload_json TEXT NOT NULL, \n\tevent_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (audit_id), \n\tUNIQUE (event_digest)\n)\n\n" + }, + "evidence_conflicts": { + "columns": [ + "conflict_id", + "project_id", + "evidence_ids_json", + "field", + "status", + "reason", + "resolution_evidence_ids_json", + "conflict_digest", + "created_at", + "updated_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_evidence_conflicts_project_id": "CREATE INDEX ix_evidence_conflicts_project_id ON evidence_conflicts (project_id)" + }, + "sql": "\nCREATE TABLE evidence_conflicts (\n\tconflict_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tevidence_ids_json TEXT NOT NULL, \n\tfield VARCHAR(160) NOT NULL, \n\tstatus VARCHAR(40) NOT NULL, \n\treason TEXT NOT NULL, \n\tresolution_evidence_ids_json TEXT NOT NULL, \n\tconflict_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tupdated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (conflict_id), \n\tUNIQUE (conflict_digest)\n)\n\n" + }, + "evidence_leads": { + "columns": [ + "lead_id", + "project_id", + "investigation_id", + "search_intent_id", + "source_id", + "span_id", + "state", + "content_json", + "lead_digest", + "created_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_evidence_leads_investigation_id": "CREATE INDEX ix_evidence_leads_investigation_id ON evidence_leads (investigation_id)", + "ix_evidence_leads_project_id": "CREATE INDEX ix_evidence_leads_project_id ON evidence_leads (project_id)", + "ix_evidence_leads_search_intent_id": "CREATE INDEX ix_evidence_leads_search_intent_id ON evidence_leads (search_intent_id)", + "ix_evidence_leads_source_id": "CREATE INDEX ix_evidence_leads_source_id ON evidence_leads (source_id)", + "ix_evidence_leads_span_id": "CREATE INDEX ix_evidence_leads_span_id ON evidence_leads (span_id)", + "ix_evidence_leads_state": "CREATE INDEX ix_evidence_leads_state ON evidence_leads (state)" + }, + "sql": "\nCREATE TABLE evidence_leads (\n\tlead_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tinvestigation_id VARCHAR(160) NOT NULL, \n\tsearch_intent_id VARCHAR(160) NOT NULL, \n\tsource_id VARCHAR(160) NOT NULL, \n\tspan_id VARCHAR(160) NOT NULL, \n\tstate VARCHAR(60) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\tlead_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (lead_id), \n\tUNIQUE (lead_digest)\n)\n\n" + }, + "evidence_links": { + "columns": [ + "evidence_id", + "project_id", + "thread_id", + "target_type", + "target_id", + "relation", + "source_ids_json", + "span_ids_json", + "observation_ids_json", + "conditions_json", + "applicability", + "independence_group", + "support_status", + "authority_status", + "verification_status", + "cutoff_eligibility", + "content_trust", + "conflict_ids_json", + "supersedes_evidence_id", + "revision", + "evidence_digest", + "created_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_evidence_links_project_id": "CREATE INDEX ix_evidence_links_project_id ON evidence_links (project_id)", + "ix_evidence_links_target_id": "CREATE INDEX ix_evidence_links_target_id ON evidence_links (target_id)" + }, + "sql": "\nCREATE TABLE evidence_links (\n\tevidence_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tthread_id VARCHAR(160), \n\ttarget_type VARCHAR(80) NOT NULL, \n\ttarget_id VARCHAR(160) NOT NULL, \n\trelation VARCHAR(40) NOT NULL, \n\tsource_ids_json TEXT NOT NULL, \n\tspan_ids_json TEXT NOT NULL, \n\tobservation_ids_json TEXT NOT NULL, \n\tconditions_json TEXT NOT NULL, \n\tapplicability TEXT NOT NULL, \n\tindependence_group VARCHAR(160) NOT NULL, \n\tsupport_status VARCHAR(60) NOT NULL, \n\tauthority_status VARCHAR(60) NOT NULL, \n\tverification_status VARCHAR(60) NOT NULL, \n\tcutoff_eligibility VARCHAR(60) NOT NULL, \n\tcontent_trust VARCHAR(60) NOT NULL, \n\tconflict_ids_json TEXT NOT NULL, \n\tsupersedes_evidence_id VARCHAR(160), \n\trevision INTEGER NOT NULL, \n\tevidence_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (evidence_id), \n\tUNIQUE (evidence_digest)\n)\n\n" + }, + "evidence_observations": { + "columns": [ + "observation_id", + "project_id", + "span_ids_json", + "observed_statement", + "observed_time", + "valid_time", + "observer_group", + "independence_basis", + "provenance_class", + "contamination_note", + "supersedes_observation_id", + "observation_digest", + "created_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_evidence_observations_project_id": "CREATE INDEX ix_evidence_observations_project_id ON evidence_observations (project_id)" + }, + "sql": "\nCREATE TABLE evidence_observations (\n\tobservation_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tspan_ids_json TEXT NOT NULL, \n\tobserved_statement TEXT NOT NULL, \n\tobserved_time VARCHAR(32), \n\tvalid_time VARCHAR(32), \n\tobserver_group VARCHAR(160) NOT NULL, \n\tindependence_basis TEXT NOT NULL, \n\tprovenance_class VARCHAR(80) NOT NULL, \n\tcontamination_note TEXT, \n\tsupersedes_observation_id VARCHAR(160), \n\tobservation_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (observation_id), \n\tUNIQUE (observation_digest)\n)\n\n" + }, + "evidence_sources": { + "columns": [ + "source_id", + "project_id", + "artifact_id", + "connector_ref", + "uri", + "artifact_type", + "version", + "sha256", + "authority_status", + "security_class", + "official_copy", + "rights", + "retention", + "valid_time", + "snapshot_time", + "cutoff_eligibility", + "lineage_root_id", + "parent_source_ids_json", + "supersedes_source_id", + "source_digest", + "created_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_evidence_sources_artifact_id": "CREATE INDEX ix_evidence_sources_artifact_id ON evidence_sources (artifact_id)", + "ix_evidence_sources_project_id": "CREATE INDEX ix_evidence_sources_project_id ON evidence_sources (project_id)" + }, + "sql": "\nCREATE TABLE evidence_sources (\n\tsource_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tartifact_id VARCHAR(160) NOT NULL, \n\tconnector_ref VARCHAR(160) NOT NULL, \n\turi TEXT NOT NULL, \n\tartifact_type VARCHAR(160) NOT NULL, \n\tversion VARCHAR(260), \n\tsha256 VARCHAR(64) NOT NULL, \n\tauthority_status VARCHAR(60) NOT NULL, \n\tsecurity_class VARCHAR(60) NOT NULL, \n\tofficial_copy INTEGER NOT NULL, \n\trights VARCHAR(160) NOT NULL, \n\tretention VARCHAR(160) NOT NULL, \n\tvalid_time VARCHAR(32), \n\tsnapshot_time VARCHAR(32), \n\tcutoff_eligibility VARCHAR(60) NOT NULL, \n\tlineage_root_id VARCHAR(160) NOT NULL, \n\tparent_source_ids_json TEXT NOT NULL, \n\tsupersedes_source_id VARCHAR(160), \n\tsource_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (source_id), \n\tUNIQUE (source_digest)\n)\n\n" + }, + "evidence_spans": { + "columns": [ + "span_id", + "project_id", + "artifact_id", + "source_version_id", + "locator_json", + "exact_text", + "text_sha256", + "extraction_method", + "support_state", + "authority_state", + "verification_state", + "cutoff_state" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "ForeignKeyConstraint", + "ForeignKeyConstraint", + "PrimaryKeyConstraint" + ], + "indexes": { + "ix_evidence_spans_project_id": "CREATE INDEX ix_evidence_spans_project_id ON evidence_spans (project_id)" + }, + "sql": "\nCREATE TABLE evidence_spans (\n\tspan_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tartifact_id VARCHAR(160) NOT NULL, \n\tsource_version_id VARCHAR(160) NOT NULL, \n\tlocator_json TEXT NOT NULL, \n\texact_text TEXT NOT NULL, \n\ttext_sha256 VARCHAR(64) NOT NULL, \n\textraction_method VARCHAR(160) NOT NULL, \n\tsupport_state VARCHAR(60) NOT NULL, \n\tauthority_state VARCHAR(60) NOT NULL, \n\tverification_state VARCHAR(60) NOT NULL, \n\tcutoff_state VARCHAR(60) NOT NULL, \n\tPRIMARY KEY (span_id), \n\tFOREIGN KEY(artifact_id) REFERENCES artifacts (artifact_id), \n\tFOREIGN KEY(source_version_id) REFERENCES artifact_versions (source_version_id)\n)\n\n" + }, + "execution_audit": { + "columns": [ + "audit_id", + "project_id", + "plan_execution_id", + "event_type", + "payload_json", + "event_digest", + "created_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_execution_audit_plan_execution_id": "CREATE INDEX ix_execution_audit_plan_execution_id ON execution_audit (plan_execution_id)", + "ix_execution_audit_project_id": "CREATE INDEX ix_execution_audit_project_id ON execution_audit (project_id)" + }, + "sql": "\nCREATE TABLE execution_audit (\n\taudit_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tplan_execution_id VARCHAR(160) NOT NULL, \n\tevent_type VARCHAR(120) NOT NULL, \n\tpayload_json TEXT NOT NULL, \n\tevent_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (audit_id), \n\tUNIQUE (event_digest)\n)\n\n" + }, + "execution_effects": { + "columns": [ + "effect_id", + "project_id", + "attempt_id", + "content_json", + "effect_digest", + "created_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_execution_effects_attempt_id": "CREATE INDEX ix_execution_effects_attempt_id ON execution_effects (attempt_id)", + "ix_execution_effects_project_id": "CREATE INDEX ix_execution_effects_project_id ON execution_effects (project_id)" + }, + "sql": "\nCREATE TABLE execution_effects (\n\teffect_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tattempt_id VARCHAR(160) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\teffect_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (effect_id), \n\tUNIQUE (effect_digest)\n)\n\n" + }, + "execution_reconciliations": { + "columns": [ + "reconciliation_id", + "project_id", + "plan_execution_id", + "attempt_id", + "content_json", + "reconciliation_digest", + "created_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_execution_reconciliations_attempt_id": "CREATE INDEX ix_execution_reconciliations_attempt_id ON execution_reconciliations (attempt_id)", + "ix_execution_reconciliations_plan_execution_id": "CREATE INDEX ix_execution_reconciliations_plan_execution_id ON execution_reconciliations (plan_execution_id)", + "ix_execution_reconciliations_project_id": "CREATE INDEX ix_execution_reconciliations_project_id ON execution_reconciliations (project_id)" + }, + "sql": "\nCREATE TABLE execution_reconciliations (\n\treconciliation_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tplan_execution_id VARCHAR(160) NOT NULL, \n\tattempt_id VARCHAR(160) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\treconciliation_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (reconciliation_id), \n\tUNIQUE (reconciliation_digest)\n)\n\n" + }, + "field_events": { + "columns": [ + "event_id", + "project_id", + "session_id", + "event_type", + "content_json" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint" + ], + "indexes": { + "ix_field_events_project_id": "CREATE INDEX ix_field_events_project_id ON field_events (project_id)", + "ix_field_events_session_id": "CREATE INDEX ix_field_events_session_id ON field_events (session_id)" + }, + "sql": "\nCREATE TABLE field_events (\n\tevent_id VARCHAR(200) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tsession_id VARCHAR(200) NOT NULL, \n\tevent_type VARCHAR(80) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\tPRIMARY KEY (event_id)\n)\n\n" + }, + "field_exports": { + "columns": [ + "export_id", + "project_id", + "protocol_digest", + "bundle_digest", + "content_json" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_field_exports_project_id": "CREATE INDEX ix_field_exports_project_id ON field_exports (project_id)" + }, + "sql": "\nCREATE TABLE field_exports (\n\texport_id VARCHAR(200) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tprotocol_digest VARCHAR(64) NOT NULL, \n\tbundle_digest VARCHAR(64) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\tPRIMARY KEY (export_id), \n\tUNIQUE (bundle_digest)\n)\n\n" + }, + "field_protocol_seals": { + "columns": [ + "protocol_id", + "project_id", + "protocol_digest", + "content_json" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_field_protocol_seals_project_id": "CREATE INDEX ix_field_protocol_seals_project_id ON field_protocol_seals (project_id)" + }, + "sql": "\nCREATE TABLE field_protocol_seals (\n\tprotocol_id VARCHAR(200) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tprotocol_digest VARCHAR(64) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\tPRIMARY KEY (protocol_id), \n\tUNIQUE (protocol_digest)\n)\n\n" + }, + "field_scores": { + "columns": [ + "score_id", + "project_id", + "session_id", + "content_json" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint" + ], + "indexes": { + "ix_field_scores_project_id": "CREATE INDEX ix_field_scores_project_id ON field_scores (project_id)", + "ix_field_scores_session_id": "CREATE INDEX ix_field_scores_session_id ON field_scores (session_id)" + }, + "sql": "\nCREATE TABLE field_scores (\n\tscore_id VARCHAR(200) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tsession_id VARCHAR(200) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\tPRIMARY KEY (score_id)\n)\n\n" + }, + "field_session_metrics": { + "columns": [ + "session_id", + "project_id", + "content_json" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint" + ], + "indexes": { + "ix_field_session_metrics_project_id": "CREATE INDEX ix_field_session_metrics_project_id ON field_session_metrics (project_id)" + }, + "sql": "\nCREATE TABLE field_session_metrics (\n\tsession_id VARCHAR(200) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\tPRIMARY KEY (session_id)\n)\n\n" + }, + "field_sessions": { + "columns": [ + "session_id", + "project_id", + "protocol_digest", + "state", + "content_json" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint" + ], + "indexes": { + "ix_field_sessions_project_id": "CREATE INDEX ix_field_sessions_project_id ON field_sessions (project_id)" + }, + "sql": "\nCREATE TABLE field_sessions (\n\tsession_id VARCHAR(200) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tprotocol_digest VARCHAR(64) NOT NULL, \n\tstate VARCHAR(40) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\tPRIMARY KEY (session_id)\n)\n\n" + }, + "governance_heads": { + "columns": [ + "project_id", + "record_kind", + "record_id", + "revision", + "record_digest" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint" + ], + "indexes": {}, + "sql": "\nCREATE TABLE governance_heads (\n\tproject_id VARCHAR(160) NOT NULL, \n\trecord_kind VARCHAR(32) NOT NULL, \n\trecord_id VARCHAR(200) NOT NULL, \n\trevision INTEGER NOT NULL, \n\trecord_digest VARCHAR(64) NOT NULL, \n\tPRIMARY KEY (project_id, record_kind, record_id)\n)\n\n" + }, + "governance_revisions": { + "columns": [ + "project_id", + "record_kind", + "record_id", + "revision", + "record_digest", + "content_json", + "receipt_digest", + "receipt_json" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint", + "UniqueConstraint" + ], + "indexes": {}, + "sql": "\nCREATE TABLE governance_revisions (\n\tproject_id VARCHAR(160) NOT NULL, \n\trecord_kind VARCHAR(32) NOT NULL, \n\trecord_id VARCHAR(200) NOT NULL, \n\trevision INTEGER NOT NULL, \n\trecord_digest VARCHAR(64) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\treceipt_digest VARCHAR(64) NOT NULL, \n\treceipt_json TEXT NOT NULL, \n\tPRIMARY KEY (project_id, record_kind, record_id, revision), \n\tUNIQUE (record_digest), \n\tUNIQUE (receipt_digest)\n)\n\n" + }, + "hypothesis_appraisals": { + "columns": [ + "appraisal_id", + "project_id", + "hypothesis_id", + "content_json", + "appraisal_digest", + "created_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_hypothesis_appraisals_hypothesis_id": "CREATE INDEX ix_hypothesis_appraisals_hypothesis_id ON hypothesis_appraisals (hypothesis_id)", + "ix_hypothesis_appraisals_project_id": "CREATE INDEX ix_hypothesis_appraisals_project_id ON hypothesis_appraisals (project_id)" + }, + "sql": "\nCREATE TABLE hypothesis_appraisals (\n\tappraisal_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\thypothesis_id VARCHAR(160) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\tappraisal_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (appraisal_id), \n\tUNIQUE (appraisal_digest)\n)\n\n" + }, + "hypothesis_assumptions": { + "columns": [ + "assumption_id", + "project_id", + "hypothesis_id", + "content_json", + "assumption_digest", + "created_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_hypothesis_assumptions_hypothesis_id": "CREATE INDEX ix_hypothesis_assumptions_hypothesis_id ON hypothesis_assumptions (hypothesis_id)", + "ix_hypothesis_assumptions_project_id": "CREATE INDEX ix_hypothesis_assumptions_project_id ON hypothesis_assumptions (project_id)" + }, + "sql": "\nCREATE TABLE hypothesis_assumptions (\n\tassumption_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\thypothesis_id VARCHAR(160) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\tassumption_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (assumption_id), \n\tUNIQUE (assumption_digest)\n)\n\n" + }, + "hypothesis_audit": { + "columns": [ + "audit_id", + "project_id", + "hypothesis_id", + "event_type", + "payload_json", + "event_digest", + "created_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_hypothesis_audit_hypothesis_id": "CREATE INDEX ix_hypothesis_audit_hypothesis_id ON hypothesis_audit (hypothesis_id)", + "ix_hypothesis_audit_project_id": "CREATE INDEX ix_hypothesis_audit_project_id ON hypothesis_audit (project_id)" + }, + "sql": "\nCREATE TABLE hypothesis_audit (\n\taudit_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\thypothesis_id VARCHAR(160) NOT NULL, \n\tevent_type VARCHAR(120) NOT NULL, \n\tpayload_json TEXT NOT NULL, \n\tevent_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (audit_id), \n\tUNIQUE (event_digest)\n)\n\n" + }, + "hypothesis_portfolios": { + "columns": [ + "portfolio_revision_id", + "portfolio_id", + "project_id", + "object_id", + "content_json", + "revision_digest", + "created_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_hypothesis_portfolios_object_id": "CREATE INDEX ix_hypothesis_portfolios_object_id ON hypothesis_portfolios (object_id)", + "ix_hypothesis_portfolios_portfolio_id": "CREATE INDEX ix_hypothesis_portfolios_portfolio_id ON hypothesis_portfolios (portfolio_id)", + "ix_hypothesis_portfolios_project_id": "CREATE INDEX ix_hypothesis_portfolios_project_id ON hypothesis_portfolios (project_id)" + }, + "sql": "\nCREATE TABLE hypothesis_portfolios (\n\tportfolio_revision_id VARCHAR(160) NOT NULL, \n\tportfolio_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tobject_id VARCHAR(160) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\trevision_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (portfolio_revision_id), \n\tUNIQUE (revision_digest)\n)\n\n" + }, + "hypothesis_predictions": { + "columns": [ + "prediction_id", + "project_id", + "hypothesis_id", + "content_json", + "prediction_digest", + "created_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_hypothesis_predictions_hypothesis_id": "CREATE INDEX ix_hypothesis_predictions_hypothesis_id ON hypothesis_predictions (hypothesis_id)", + "ix_hypothesis_predictions_project_id": "CREATE INDEX ix_hypothesis_predictions_project_id ON hypothesis_predictions (project_id)" + }, + "sql": "\nCREATE TABLE hypothesis_predictions (\n\tprediction_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\thypothesis_id VARCHAR(160) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\tprediction_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (prediction_id), \n\tUNIQUE (prediction_digest)\n)\n\n" + }, + "hypothesis_records": { + "columns": [ + "hypothesis_revision_id", + "hypothesis_id", + "project_id", + "object_id", + "portfolio_id", + "content_json", + "revision_digest", + "created_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_hypothesis_records_hypothesis_id": "CREATE INDEX ix_hypothesis_records_hypothesis_id ON hypothesis_records (hypothesis_id)", + "ix_hypothesis_records_object_id": "CREATE INDEX ix_hypothesis_records_object_id ON hypothesis_records (object_id)", + "ix_hypothesis_records_portfolio_id": "CREATE INDEX ix_hypothesis_records_portfolio_id ON hypothesis_records (portfolio_id)", + "ix_hypothesis_records_project_id": "CREATE INDEX ix_hypothesis_records_project_id ON hypothesis_records (project_id)" + }, + "sql": "\nCREATE TABLE hypothesis_records (\n\thypothesis_revision_id VARCHAR(160) NOT NULL, \n\thypothesis_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tobject_id VARCHAR(160) NOT NULL, \n\tportfolio_id VARCHAR(160) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\trevision_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (hypothesis_revision_id), \n\tUNIQUE (revision_digest)\n)\n\n" + }, + "hypothesis_relations": { + "columns": [ + "relation_revision_id", + "relation_id", + "project_id", + "portfolio_id", + "content_json", + "revision_digest", + "created_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_hypothesis_relations_portfolio_id": "CREATE INDEX ix_hypothesis_relations_portfolio_id ON hypothesis_relations (portfolio_id)", + "ix_hypothesis_relations_project_id": "CREATE INDEX ix_hypothesis_relations_project_id ON hypothesis_relations (project_id)", + "ix_hypothesis_relations_relation_id": "CREATE INDEX ix_hypothesis_relations_relation_id ON hypothesis_relations (relation_id)" + }, + "sql": "\nCREATE TABLE hypothesis_relations (\n\trelation_revision_id VARCHAR(160) NOT NULL, \n\trelation_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tportfolio_id VARCHAR(160) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\trevision_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (relation_revision_id), \n\tUNIQUE (revision_digest)\n)\n\n" + }, + "hypothesis_test_bindings": { + "columns": [ + "test_binding_id", + "project_id", + "prediction_id", + "content_json", + "binding_digest", + "created_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_hypothesis_test_bindings_prediction_id": "CREATE INDEX ix_hypothesis_test_bindings_prediction_id ON hypothesis_test_bindings (prediction_id)", + "ix_hypothesis_test_bindings_project_id": "CREATE INDEX ix_hypothesis_test_bindings_project_id ON hypothesis_test_bindings (project_id)" + }, + "sql": "\nCREATE TABLE hypothesis_test_bindings (\n\ttest_binding_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tprediction_id VARCHAR(160) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\tbinding_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (test_binding_id), \n\tUNIQUE (binding_digest)\n)\n\n" + }, + "idempotency_keys": { + "columns": [ + "project_id", + "method", + "idempotency_key", + "scope_digest", + "operation_id" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "ForeignKeyConstraint", + "PrimaryKeyConstraint" + ], + "indexes": {}, + "sql": "\nCREATE TABLE idempotency_keys (\n\tproject_id VARCHAR(160) NOT NULL, \n\tmethod VARCHAR(160) NOT NULL, \n\tidempotency_key VARCHAR(260) NOT NULL, \n\tscope_digest VARCHAR(64) NOT NULL, \n\toperation_id VARCHAR(160) NOT NULL, \n\tPRIMARY KEY (project_id, method, idempotency_key), \n\tFOREIGN KEY(operation_id) REFERENCES operations (operation_id)\n)\n\n" + }, + "investigation_audit": { + "columns": [ + "audit_id", + "project_id", + "investigation_id", + "event_type", + "payload_json", + "event_digest", + "created_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_investigation_audit_investigation_id": "CREATE INDEX ix_investigation_audit_investigation_id ON investigation_audit (investigation_id)", + "ix_investigation_audit_project_id": "CREATE INDEX ix_investigation_audit_project_id ON investigation_audit (project_id)" + }, + "sql": "\nCREATE TABLE investigation_audit (\n\taudit_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tinvestigation_id VARCHAR(160) NOT NULL, \n\tevent_type VARCHAR(120) NOT NULL, \n\tpayload_json TEXT NOT NULL, \n\tevent_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (audit_id), \n\tUNIQUE (event_digest)\n)\n\n" + }, + "investigations": { + "columns": [ + "investigation_id", + "project_id", + "thread_id", + "cycle_id", + "parent_investigation_id", + "trigger", + "question", + "target_object_id", + "target_hypothesis_id", + "mode", + "scope_json", + "required_evidence_groups_json", + "query_families_json", + "counter_search_policy", + "budget", + "budget_usage", + "stop_conditions_json", + "domain_state", + "execution_state", + "current_wave", + "observation_count", + "open_lead_count", + "claim_candidate_count", + "gap_count", + "sufficiency_json", + "checkpoint_digest", + "result_json", + "plan_revision", + "investigation_digest", + "created_at", + "updated_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_investigations_project_id": "CREATE INDEX ix_investigations_project_id ON investigations (project_id)", + "ix_investigations_thread_id": "CREATE INDEX ix_investigations_thread_id ON investigations (thread_id)" + }, + "sql": "\nCREATE TABLE investigations (\n\tinvestigation_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tthread_id VARCHAR(160) NOT NULL, \n\tcycle_id VARCHAR(160) NOT NULL, \n\tparent_investigation_id VARCHAR(160), \n\t\"trigger\" VARCHAR(80) NOT NULL, \n\tquestion TEXT NOT NULL, \n\ttarget_object_id VARCHAR(160), \n\ttarget_hypothesis_id VARCHAR(160), \n\tmode VARCHAR(40) NOT NULL, \n\tscope_json TEXT NOT NULL, \n\trequired_evidence_groups_json TEXT NOT NULL, \n\tquery_families_json TEXT NOT NULL, \n\tcounter_search_policy VARCHAR(160) NOT NULL, \n\tbudget INTEGER NOT NULL, \n\tbudget_usage INTEGER NOT NULL, \n\tstop_conditions_json TEXT NOT NULL, \n\tdomain_state VARCHAR(40) NOT NULL, \n\texecution_state VARCHAR(40) NOT NULL, \n\tcurrent_wave INTEGER NOT NULL, \n\tobservation_count INTEGER NOT NULL, \n\topen_lead_count INTEGER NOT NULL, \n\tclaim_candidate_count INTEGER NOT NULL, \n\tgap_count INTEGER NOT NULL, \n\tsufficiency_json TEXT NOT NULL, \n\tcheckpoint_digest VARCHAR(64), \n\tresult_json TEXT, \n\tplan_revision INTEGER NOT NULL, \n\tinvestigation_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tupdated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (investigation_id), \n\tUNIQUE (investigation_digest)\n)\n\n" + }, + "memory_context_packs": { + "columns": [ + "context_pack_id", + "project_id", + "thread_id", + "content_json", + "query_digest" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint" + ], + "indexes": { + "ix_memory_context_packs_project_id": "CREATE INDEX ix_memory_context_packs_project_id ON memory_context_packs (project_id)" + }, + "sql": "\nCREATE TABLE memory_context_packs (\n\tcontext_pack_id VARCHAR(200) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tthread_id VARCHAR(160) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\tquery_digest VARCHAR(64) NOT NULL, \n\tPRIMARY KEY (context_pack_id)\n)\n\n" + }, + "memory_projections": { + "columns": [ + "projection_id", + "project_id", + "projection_type", + "content_json", + "rebuild_checkpoint" + ], + "constraint_names": [ + null, + "uq_memory_projection_type" + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_memory_projections_project_id": "CREATE INDEX ix_memory_projections_project_id ON memory_projections (project_id)" + }, + "sql": "\nCREATE TABLE memory_projections (\n\tprojection_id VARCHAR(200) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tprojection_type VARCHAR(40) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\trebuild_checkpoint VARCHAR(64) NOT NULL, \n\tPRIMARY KEY (projection_id), \n\tCONSTRAINT uq_memory_projection_type UNIQUE (project_id, projection_type)\n)\n\n" + }, + "memory_records": { + "columns": [ + "memory_id", + "project_id", + "payload_mode", + "kind", + "owner_revision_ref", + "source_ref", + "assertion", + "recall_eligibility", + "lifecycle", + "revision_digest" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint" + ], + "indexes": { + "ix_memory_records_project_id": "CREATE INDEX ix_memory_records_project_id ON memory_records (project_id)" + }, + "sql": "\nCREATE TABLE memory_records (\n\tmemory_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tpayload_mode VARCHAR(60) NOT NULL, \n\tkind VARCHAR(60) NOT NULL, \n\towner_revision_ref VARCHAR(160) NOT NULL, \n\tsource_ref VARCHAR(260), \n\tassertion TEXT, \n\trecall_eligibility VARCHAR(60) NOT NULL, \n\tlifecycle VARCHAR(60) NOT NULL, \n\trevision_digest VARCHAR(64) NOT NULL, \n\tPRIMARY KEY (memory_id)\n)\n\n" + }, + "memory_revision_ledger": { + "columns": [ + "memory_revision_id", + "memory_id", + "project_id", + "transition", + "owner_revision_ref", + "content_json", + "revision_digest" + ], + "constraint_names": [ + null, + "uq_memory_revision_source_memory" + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_memory_revision_ledger_project_id": "CREATE INDEX ix_memory_revision_ledger_project_id ON memory_revision_ledger (project_id)" + }, + "sql": "\nCREATE TABLE memory_revision_ledger (\n\tmemory_revision_id VARCHAR(200) NOT NULL, \n\tmemory_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\ttransition VARCHAR(40) NOT NULL, \n\towner_revision_ref VARCHAR(64) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\trevision_digest VARCHAR(64) NOT NULL, \n\tPRIMARY KEY (memory_revision_id), \n\tCONSTRAINT uq_memory_revision_source_memory UNIQUE (project_id, memory_id), \n\tUNIQUE (revision_digest)\n)\n\n" + }, + "memory_transition_receipts": { + "columns": [ + "receipt_id", + "project_id", + "memory_revision_id", + "content_json", + "receipt_digest" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_memory_transition_receipts_project_id": "CREATE INDEX ix_memory_transition_receipts_project_id ON memory_transition_receipts (project_id)" + }, + "sql": "\nCREATE TABLE memory_transition_receipts (\n\treceipt_id VARCHAR(200) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tmemory_revision_id VARCHAR(200) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\treceipt_digest VARCHAR(64) NOT NULL, \n\tPRIMARY KEY (receipt_id), \n\tUNIQUE (memory_revision_id), \n\tUNIQUE (receipt_digest)\n)\n\n" + }, + "object_attention": { + "columns": [ + "attention_revision_id", + "attention_id", + "project_id", + "object_id", + "content_json", + "revision_digest", + "created_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_object_attention_attention_id": "CREATE INDEX ix_object_attention_attention_id ON object_attention (attention_id)", + "ix_object_attention_object_id": "CREATE INDEX ix_object_attention_object_id ON object_attention (object_id)", + "ix_object_attention_project_id": "CREATE INDEX ix_object_attention_project_id ON object_attention (project_id)" + }, + "sql": "\nCREATE TABLE object_attention (\n\tattention_revision_id VARCHAR(160) NOT NULL, \n\tattention_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tobject_id VARCHAR(160) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\trevision_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (attention_revision_id), \n\tUNIQUE (revision_digest)\n)\n\n" + }, + "object_audit": { + "columns": [ + "audit_id", + "project_id", + "object_id", + "event_type", + "payload_json", + "event_digest", + "created_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_object_audit_object_id": "CREATE INDEX ix_object_audit_object_id ON object_audit (object_id)", + "ix_object_audit_project_id": "CREATE INDEX ix_object_audit_project_id ON object_audit (project_id)" + }, + "sql": "\nCREATE TABLE object_audit (\n\taudit_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tobject_id VARCHAR(160) NOT NULL, \n\tevent_type VARCHAR(120) NOT NULL, \n\tpayload_json TEXT NOT NULL, \n\tevent_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (audit_id), \n\tUNIQUE (event_digest)\n)\n\n" + }, + "object_candidates": { + "columns": [ + "candidate_id", + "project_id", + "thread_id", + "content_json", + "candidate_digest", + "created_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_object_candidates_project_id": "CREATE INDEX ix_object_candidates_project_id ON object_candidates (project_id)", + "ix_object_candidates_thread_id": "CREATE INDEX ix_object_candidates_thread_id ON object_candidates (thread_id)" + }, + "sql": "\nCREATE TABLE object_candidates (\n\tcandidate_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tthread_id VARCHAR(160) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\tcandidate_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (candidate_id), \n\tUNIQUE (candidate_digest)\n)\n\n" + }, + "object_profiles": { + "columns": [ + "profile_ref", + "version", + "content_json", + "enabled", + "profile_digest" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": {}, + "sql": "\nCREATE TABLE object_profiles (\n\tprofile_ref VARCHAR(160) NOT NULL, \n\tversion INTEGER NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\tenabled INTEGER NOT NULL, \n\tprofile_digest VARCHAR(64) NOT NULL, \n\tPRIMARY KEY (profile_ref, version), \n\tUNIQUE (profile_digest)\n)\n\n" + }, + "object_relations": { + "columns": [ + "relation_revision_id", + "relation_id", + "project_id", + "source_object_id", + "content_json", + "revision_digest", + "created_at" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint", + "UniqueConstraint" + ], + "indexes": { + "ix_object_relations_project_id": "CREATE INDEX ix_object_relations_project_id ON object_relations (project_id)", + "ix_object_relations_relation_id": "CREATE INDEX ix_object_relations_relation_id ON object_relations (relation_id)", + "ix_object_relations_source_object_id": "CREATE INDEX ix_object_relations_source_object_id ON object_relations (source_object_id)" + }, + "sql": "\nCREATE TABLE object_relations (\n\trelation_revision_id VARCHAR(160) NOT NULL, \n\trelation_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tsource_object_id VARCHAR(160) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\trevision_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (relation_revision_id), \n\tUNIQUE (revision_digest)\n)\n\n" + }, + "operation_owners": { + "columns": [ + "operation_id", + "worker_id", + "process_id", + "process_marker", + "host_token" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint" + ], + "indexes": {}, + "sql": "\nCREATE TABLE operation_owners (\n\toperation_id VARCHAR(160) NOT NULL, \n\tworker_id VARCHAR(160) NOT NULL, \n\tprocess_id INTEGER NOT NULL, \n\tprocess_marker VARCHAR(160) NOT NULL, \n\thost_token VARCHAR(160) NOT NULL, \n\tPRIMARY KEY (operation_id)\n)\n\n" + }, "operation_resource_bindings": { "columns": [ "operation_id", @@ -3215,7 +6474,7 @@ } } }, - "thoth.adapters.storage.evaluation_run_schema": { + "thoth.adapters.storage.governance_history_schema": { "consumer_aliases": { "thoth.adapters.storage.acquisition.artifact_versions": "artifact_versions", "thoth.adapters.storage.acquisition.artifacts": "artifacts", @@ -3310,6 +6569,8 @@ "thoth.adapters.storage.memory.memory_records": "memory_records", "thoth.adapters.storage.memory.memory_revision_ledger": "memory_revision_ledger", "thoth.adapters.storage.memory.memory_transition_receipts": "memory_transition_receipts", + "thoth.adapters.storage.operation_owner_schema.operation_owners": "operation_owners", + "thoth.adapters.storage.operation_owner_store.operation_owners": "operation_owners", "thoth.adapters.storage.operations.idempotency_keys": "idempotency_keys", "thoth.adapters.storage.operations.memory_records": "memory_records", "thoth.adapters.storage.operations.memory_revision_ledger": "memory_revision_ledger", @@ -3544,13 +6805,14 @@ "governance_revisions", "governance_heads", "research_identities", + "operation_owners", "resource_scope_history", "resource_scope_heads", "resource_scope_receipts", "operation_resource_bindings", - "evaluation_runs", "behavior_exposure_history", "behavior_baseline_history", + "evaluation_runs", "research_test_assessments", "semantic_revisions", "artifact_versions", @@ -3821,6 +7083,7 @@ "object_candidates", "object_profiles", "object_relations", + "operation_owners", "operation_resource_bindings", "operations", "organizations", @@ -3969,13 +7232,14 @@ "governance_revisions", "governance_heads", "research_identities", + "operation_owners", "resource_scope_history", "resource_scope_heads", "resource_scope_receipts", "operation_resource_bindings", - "evaluation_runs", "behavior_exposure_history", "behavior_baseline_history", + "evaluation_runs", "research_test_assessments" ], "tables": { @@ -5540,6 +8804,23 @@ }, "sql": "\nCREATE TABLE object_relations (\n\trelation_revision_id VARCHAR(160) NOT NULL, \n\trelation_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tsource_object_id VARCHAR(160) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\trevision_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (relation_revision_id), \n\tUNIQUE (revision_digest)\n)\n\n" }, + "operation_owners": { + "columns": [ + "operation_id", + "worker_id", + "process_id", + "process_marker", + "host_token" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint" + ], + "indexes": {}, + "sql": "\nCREATE TABLE operation_owners (\n\toperation_id VARCHAR(160) NOT NULL, \n\tworker_id VARCHAR(160) NOT NULL, \n\tprocess_id INTEGER NOT NULL, \n\tprocess_marker VARCHAR(160) NOT NULL, \n\thost_token VARCHAR(160) NOT NULL, \n\tPRIMARY KEY (operation_id)\n)\n\n" + }, "operation_resource_bindings": { "columns": [ "operation_id", @@ -6430,7 +9711,7 @@ } } }, - "thoth.adapters.storage.governance_history_schema": { + "thoth.adapters.storage.operation_owner_schema": { "consumer_aliases": { "thoth.adapters.storage.acquisition.artifact_versions": "artifact_versions", "thoth.adapters.storage.acquisition.artifacts": "artifacts", @@ -6525,6 +9806,8 @@ "thoth.adapters.storage.memory.memory_records": "memory_records", "thoth.adapters.storage.memory.memory_revision_ledger": "memory_revision_ledger", "thoth.adapters.storage.memory.memory_transition_receipts": "memory_transition_receipts", + "thoth.adapters.storage.operation_owner_schema.operation_owners": "operation_owners", + "thoth.adapters.storage.operation_owner_store.operation_owners": "operation_owners", "thoth.adapters.storage.operations.idempotency_keys": "idempotency_keys", "thoth.adapters.storage.operations.memory_records": "memory_records", "thoth.adapters.storage.operations.memory_revision_ledger": "memory_revision_ledger", @@ -6759,6 +10042,7 @@ "governance_revisions", "governance_heads", "research_identities", + "operation_owners", "resource_scope_history", "resource_scope_heads", "resource_scope_receipts", @@ -7036,6 +10320,7 @@ "object_candidates", "object_profiles", "object_relations", + "operation_owners", "operation_resource_bindings", "operations", "organizations", @@ -7184,6 +10469,7 @@ "governance_revisions", "governance_heads", "research_identities", + "operation_owners", "resource_scope_history", "resource_scope_heads", "resource_scope_receipts", @@ -8755,6 +12041,23 @@ }, "sql": "\nCREATE TABLE object_relations (\n\trelation_revision_id VARCHAR(160) NOT NULL, \n\trelation_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tsource_object_id VARCHAR(160) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\trevision_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (relation_revision_id), \n\tUNIQUE (revision_digest)\n)\n\n" }, + "operation_owners": { + "columns": [ + "operation_id", + "worker_id", + "process_id", + "process_marker", + "host_token" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint" + ], + "indexes": {}, + "sql": "\nCREATE TABLE operation_owners (\n\toperation_id VARCHAR(160) NOT NULL, \n\tworker_id VARCHAR(160) NOT NULL, \n\tprocess_id INTEGER NOT NULL, \n\tprocess_marker VARCHAR(160) NOT NULL, \n\thost_token VARCHAR(160) NOT NULL, \n\tPRIMARY KEY (operation_id)\n)\n\n" + }, "operation_resource_bindings": { "columns": [ "operation_id", @@ -9740,6 +13043,8 @@ "thoth.adapters.storage.memory.memory_records": "memory_records", "thoth.adapters.storage.memory.memory_revision_ledger": "memory_revision_ledger", "thoth.adapters.storage.memory.memory_transition_receipts": "memory_transition_receipts", + "thoth.adapters.storage.operation_owner_schema.operation_owners": "operation_owners", + "thoth.adapters.storage.operation_owner_store.operation_owners": "operation_owners", "thoth.adapters.storage.operations.idempotency_keys": "idempotency_keys", "thoth.adapters.storage.operations.memory_records": "memory_records", "thoth.adapters.storage.operations.memory_revision_ledger": "memory_revision_ledger", @@ -9974,6 +13279,7 @@ "governance_revisions", "governance_heads", "research_identities", + "operation_owners", "resource_scope_history", "resource_scope_heads", "resource_scope_receipts", @@ -10251,6 +13557,7 @@ "object_candidates", "object_profiles", "object_relations", + "operation_owners", "operation_resource_bindings", "operations", "organizations", @@ -10399,6 +13706,7 @@ "governance_revisions", "governance_heads", "research_identities", + "operation_owners", "resource_scope_history", "resource_scope_heads", "resource_scope_receipts", @@ -11970,6 +15278,23 @@ }, "sql": "\nCREATE TABLE object_relations (\n\trelation_revision_id VARCHAR(160) NOT NULL, \n\trelation_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tsource_object_id VARCHAR(160) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\trevision_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (relation_revision_id), \n\tUNIQUE (revision_digest)\n)\n\n" }, + "operation_owners": { + "columns": [ + "operation_id", + "worker_id", + "process_id", + "process_marker", + "host_token" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint" + ], + "indexes": {}, + "sql": "\nCREATE TABLE operation_owners (\n\toperation_id VARCHAR(160) NOT NULL, \n\tworker_id VARCHAR(160) NOT NULL, \n\tprocess_id INTEGER NOT NULL, \n\tprocess_marker VARCHAR(160) NOT NULL, \n\thost_token VARCHAR(160) NOT NULL, \n\tPRIMARY KEY (operation_id)\n)\n\n" + }, "operation_resource_bindings": { "columns": [ "operation_id", @@ -12955,6 +16280,8 @@ "thoth.adapters.storage.memory.memory_records": "memory_records", "thoth.adapters.storage.memory.memory_revision_ledger": "memory_revision_ledger", "thoth.adapters.storage.memory.memory_transition_receipts": "memory_transition_receipts", + "thoth.adapters.storage.operation_owner_schema.operation_owners": "operation_owners", + "thoth.adapters.storage.operation_owner_store.operation_owners": "operation_owners", "thoth.adapters.storage.operations.idempotency_keys": "idempotency_keys", "thoth.adapters.storage.operations.memory_records": "memory_records", "thoth.adapters.storage.operations.memory_revision_ledger": "memory_revision_ledger", @@ -13189,6 +16516,7 @@ "governance_revisions", "governance_heads", "research_identities", + "operation_owners", "resource_scope_history", "resource_scope_heads", "resource_scope_receipts", @@ -13466,6 +16794,7 @@ "object_candidates", "object_profiles", "object_relations", + "operation_owners", "operation_resource_bindings", "operations", "organizations", @@ -13614,6 +16943,7 @@ "governance_revisions", "governance_heads", "research_identities", + "operation_owners", "resource_scope_history", "resource_scope_heads", "resource_scope_receipts", @@ -15185,6 +18515,23 @@ }, "sql": "\nCREATE TABLE object_relations (\n\trelation_revision_id VARCHAR(160) NOT NULL, \n\trelation_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tsource_object_id VARCHAR(160) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\trevision_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (relation_revision_id), \n\tUNIQUE (revision_digest)\n)\n\n" }, + "operation_owners": { + "columns": [ + "operation_id", + "worker_id", + "process_id", + "process_marker", + "host_token" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint" + ], + "indexes": {}, + "sql": "\nCREATE TABLE operation_owners (\n\toperation_id VARCHAR(160) NOT NULL, \n\tworker_id VARCHAR(160) NOT NULL, \n\tprocess_id INTEGER NOT NULL, \n\tprocess_marker VARCHAR(160) NOT NULL, \n\thost_token VARCHAR(160) NOT NULL, \n\tPRIMARY KEY (operation_id)\n)\n\n" + }, "operation_resource_bindings": { "columns": [ "operation_id", @@ -16170,6 +19517,8 @@ "thoth.adapters.storage.memory.memory_records": "memory_records", "thoth.adapters.storage.memory.memory_revision_ledger": "memory_revision_ledger", "thoth.adapters.storage.memory.memory_transition_receipts": "memory_transition_receipts", + "thoth.adapters.storage.operation_owner_schema.operation_owners": "operation_owners", + "thoth.adapters.storage.operation_owner_store.operation_owners": "operation_owners", "thoth.adapters.storage.operations.idempotency_keys": "idempotency_keys", "thoth.adapters.storage.operations.memory_records": "memory_records", "thoth.adapters.storage.operations.memory_revision_ledger": "memory_revision_ledger", @@ -16404,6 +19753,7 @@ "governance_revisions", "governance_heads", "research_identities", + "operation_owners", "resource_scope_history", "resource_scope_heads", "resource_scope_receipts", @@ -16681,6 +20031,7 @@ "object_candidates", "object_profiles", "object_relations", + "operation_owners", "operation_resource_bindings", "operations", "organizations", @@ -16829,6 +20180,7 @@ "governance_revisions", "governance_heads", "research_identities", + "operation_owners", "resource_scope_history", "resource_scope_heads", "resource_scope_receipts", @@ -18400,6 +21752,23 @@ }, "sql": "\nCREATE TABLE object_relations (\n\trelation_revision_id VARCHAR(160) NOT NULL, \n\trelation_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tsource_object_id VARCHAR(160) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\trevision_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (relation_revision_id), \n\tUNIQUE (revision_digest)\n)\n\n" }, + "operation_owners": { + "columns": [ + "operation_id", + "worker_id", + "process_id", + "process_marker", + "host_token" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint" + ], + "indexes": {}, + "sql": "\nCREATE TABLE operation_owners (\n\toperation_id VARCHAR(160) NOT NULL, \n\tworker_id VARCHAR(160) NOT NULL, \n\tprocess_id INTEGER NOT NULL, \n\tprocess_marker VARCHAR(160) NOT NULL, \n\thost_token VARCHAR(160) NOT NULL, \n\tPRIMARY KEY (operation_id)\n)\n\n" + }, "operation_resource_bindings": { "columns": [ "operation_id", @@ -19385,6 +22754,8 @@ "thoth.adapters.storage.memory.memory_records": "memory_records", "thoth.adapters.storage.memory.memory_revision_ledger": "memory_revision_ledger", "thoth.adapters.storage.memory.memory_transition_receipts": "memory_transition_receipts", + "thoth.adapters.storage.operation_owner_schema.operation_owners": "operation_owners", + "thoth.adapters.storage.operation_owner_store.operation_owners": "operation_owners", "thoth.adapters.storage.operations.idempotency_keys": "idempotency_keys", "thoth.adapters.storage.operations.memory_records": "memory_records", "thoth.adapters.storage.operations.memory_revision_ledger": "memory_revision_ledger", @@ -19619,6 +22990,7 @@ "governance_revisions", "governance_heads", "research_identities", + "operation_owners", "resource_scope_history", "resource_scope_heads", "resource_scope_receipts", @@ -19896,6 +23268,7 @@ "object_candidates", "object_profiles", "object_relations", + "operation_owners", "operation_resource_bindings", "operations", "organizations", @@ -20044,6 +23417,7 @@ "governance_revisions", "governance_heads", "research_identities", + "operation_owners", "resource_scope_history", "resource_scope_heads", "resource_scope_receipts", @@ -21615,6 +24989,23 @@ }, "sql": "\nCREATE TABLE object_relations (\n\trelation_revision_id VARCHAR(160) NOT NULL, \n\trelation_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tsource_object_id VARCHAR(160) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\trevision_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (relation_revision_id), \n\tUNIQUE (revision_digest)\n)\n\n" }, + "operation_owners": { + "columns": [ + "operation_id", + "worker_id", + "process_id", + "process_marker", + "host_token" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint" + ], + "indexes": {}, + "sql": "\nCREATE TABLE operation_owners (\n\toperation_id VARCHAR(160) NOT NULL, \n\tworker_id VARCHAR(160) NOT NULL, \n\tprocess_id INTEGER NOT NULL, \n\tprocess_marker VARCHAR(160) NOT NULL, \n\thost_token VARCHAR(160) NOT NULL, \n\tPRIMARY KEY (operation_id)\n)\n\n" + }, "operation_resource_bindings": { "columns": [ "operation_id", @@ -22600,6 +25991,8 @@ "thoth.adapters.storage.memory.memory_records": "memory_records", "thoth.adapters.storage.memory.memory_revision_ledger": "memory_revision_ledger", "thoth.adapters.storage.memory.memory_transition_receipts": "memory_transition_receipts", + "thoth.adapters.storage.operation_owner_schema.operation_owners": "operation_owners", + "thoth.adapters.storage.operation_owner_store.operation_owners": "operation_owners", "thoth.adapters.storage.operations.idempotency_keys": "idempotency_keys", "thoth.adapters.storage.operations.memory_records": "memory_records", "thoth.adapters.storage.operations.memory_revision_ledger": "memory_revision_ledger", @@ -22834,6 +26227,7 @@ "governance_revisions", "governance_heads", "research_identities", + "operation_owners", "resource_scope_history", "resource_scope_heads", "resource_scope_receipts", @@ -23111,6 +26505,7 @@ "object_candidates", "object_profiles", "object_relations", + "operation_owners", "operation_resource_bindings", "operations", "organizations", @@ -23259,6 +26654,7 @@ "governance_revisions", "governance_heads", "research_identities", + "operation_owners", "resource_scope_history", "resource_scope_heads", "resource_scope_receipts", @@ -24830,6 +28226,23 @@ }, "sql": "\nCREATE TABLE object_relations (\n\trelation_revision_id VARCHAR(160) NOT NULL, \n\trelation_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tsource_object_id VARCHAR(160) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\trevision_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (relation_revision_id), \n\tUNIQUE (revision_digest)\n)\n\n" }, + "operation_owners": { + "columns": [ + "operation_id", + "worker_id", + "process_id", + "process_marker", + "host_token" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint" + ], + "indexes": {}, + "sql": "\nCREATE TABLE operation_owners (\n\toperation_id VARCHAR(160) NOT NULL, \n\tworker_id VARCHAR(160) NOT NULL, \n\tprocess_id INTEGER NOT NULL, \n\tprocess_marker VARCHAR(160) NOT NULL, \n\thost_token VARCHAR(160) NOT NULL, \n\tPRIMARY KEY (operation_id)\n)\n\n" + }, "operation_resource_bindings": { "columns": [ "operation_id", @@ -25818,6 +29231,8 @@ "thoth.adapters.storage.memory.memory_records": "memory_records", "thoth.adapters.storage.memory.memory_revision_ledger": "memory_revision_ledger", "thoth.adapters.storage.memory.memory_transition_receipts": "memory_transition_receipts", + "thoth.adapters.storage.operation_owner_schema.operation_owners": "operation_owners", + "thoth.adapters.storage.operation_owner_store.operation_owners": "operation_owners", "thoth.adapters.storage.operations.idempotency_keys": "idempotency_keys", "thoth.adapters.storage.operations.memory_records": "memory_records", "thoth.adapters.storage.operations.memory_revision_ledger": "memory_revision_ledger", @@ -26063,6 +29478,7 @@ "governance_revisions", "governance_heads", "research_identities", + "operation_owners", "resource_scope_history", "resource_scope_heads", "resource_scope_receipts", @@ -26340,6 +29756,7 @@ "object_candidates", "object_profiles", "object_relations", + "operation_owners", "operation_resource_bindings", "operations", "organizations", @@ -26488,6 +29905,7 @@ "governance_revisions", "governance_heads", "research_identities", + "operation_owners", "resource_scope_history", "resource_scope_heads", "resource_scope_receipts", @@ -28059,6 +31477,23 @@ }, "sql": "\nCREATE TABLE object_relations (\n\trelation_revision_id VARCHAR(160) NOT NULL, \n\trelation_id VARCHAR(160) NOT NULL, \n\tproject_id VARCHAR(160) NOT NULL, \n\tsource_object_id VARCHAR(160) NOT NULL, \n\tcontent_json TEXT NOT NULL, \n\trevision_digest VARCHAR(64) NOT NULL, \n\tcreated_at VARCHAR(32) NOT NULL, \n\tPRIMARY KEY (relation_revision_id), \n\tUNIQUE (revision_digest)\n)\n\n" }, + "operation_owners": { + "columns": [ + "operation_id", + "worker_id", + "process_id", + "process_marker", + "host_token" + ], + "constraint_names": [ + null + ], + "constraint_types": [ + "PrimaryKeyConstraint" + ], + "indexes": {}, + "sql": "\nCREATE TABLE operation_owners (\n\toperation_id VARCHAR(160) NOT NULL, \n\tworker_id VARCHAR(160) NOT NULL, \n\tprocess_id INTEGER NOT NULL, \n\tprocess_marker VARCHAR(160) NOT NULL, \n\thost_token VARCHAR(160) NOT NULL, \n\tPRIMARY KEY (operation_id)\n)\n\n" + }, "operation_resource_bindings": { "columns": [ "operation_id", diff --git a/tests/integration/migration_head_helpers.py b/tests/integration/migration_head_helpers.py index afbefad..b2e2c08 100644 --- a/tests/integration/migration_head_helpers.py +++ b/tests/integration/migration_head_helpers.py @@ -6,8 +6,8 @@ from alembic.script import ScriptDirectory ROOT = Path(__file__).resolve().parents[2] -CURRENT_HEAD = "3b06e4f87d92" -PREVIOUS_HEAD = "2af5d3e76c81" +CURRENT_HEAD = "4c17f5089ea3" +PREVIOUS_HEAD = "3b06e4f87d92" def current_single_head() -> str: diff --git a/tests/integration/schema_contract_probe.py b/tests/integration/schema_contract_probe.py index ea86f58..318f543 100644 --- a/tests/integration/schema_contract_probe.py +++ b/tests/integration/schema_contract_probe.py @@ -23,6 +23,7 @@ "thoth.adapters.storage.evaluation_run_schema", "thoth.adapters.storage.test_validity_schema", "thoth.adapters.storage.governance_history_schema", + "thoth.adapters.storage.operation_owner_schema", "thoth.apps.runtime", ) EXTENSIONS = ( @@ -32,6 +33,7 @@ "resource_scope_schema", "test_validity_schema", "governance_history_schema", + "operation_owner_schema", ) diff --git a/tests/integration/storage_coverage_helpers.py b/tests/integration/storage_coverage_helpers.py index 6387888..657e367 100644 --- a/tests/integration/storage_coverage_helpers.py +++ b/tests/integration/storage_coverage_helpers.py @@ -20,6 +20,7 @@ "events", "checkpoints", "operation_resource_bindings", + "operation_owners", } diff --git a/tests/integration/test_action_step_add_step_id.py b/tests/integration/test_action_step_add_step_id.py index 11ce5bd..84c8a57 100644 --- a/tests/integration/test_action_step_add_step_id.py +++ b/tests/integration/test_action_step_add_step_id.py @@ -14,7 +14,12 @@ from typing import Any import pytest -from tests.atomicity.harness import assert_phase_delta, failed_command_allowances, snapshot +from tests.atomicity.harness import ( + assert_phase_delta, + failed_command_allowances, + snapshot, + unchanged_or_operation_owner, +) from tests.integration.scoped_runtime import create_runtime from tests.integration.storage_coverage_helpers import request, value from tests.integration.test_a03_storage_atomicity import setup @@ -119,7 +124,10 @@ async def test_failed_step_add_never_publishes_a_plan_revision( assert named in await plan_steps(runtime, ctx, "d-step-read-success") return domain_changed = sorted( - name for name in before if name not in BOOKKEEPING and before[name] != after[name] + name + for name in before + if name not in BOOKKEEPING + and not unchanged_or_operation_owner(before, after, name, command) ) assert domain_changed == [], ("error response left published rows", domain_changed) assert heads_after == heads_before diff --git a/tests/integration/test_d1_object_followup_atomicity.py b/tests/integration/test_d1_object_followup_atomicity.py index 028415d..f99d28d 100644 --- a/tests/integration/test_d1_object_followup_atomicity.py +++ b/tests/integration/test_d1_object_followup_atomicity.py @@ -305,7 +305,11 @@ def wrapped(instance: Any, *args: Any, **kwargs: Any) -> Any: "idempotency_keys", "events", "operation_resource_bindings", - } + } | ( + {"operation_owners"} + if before["operation_owners"] != after["operation_owners"] + else set() + ) finally: runtime.close() reopened = create_runtime(tmp_path) diff --git a/tests/integration/test_m3_action_authorization_atomicity.py b/tests/integration/test_m3_action_authorization_atomicity.py index af99fea..17ed7b2 100644 --- a/tests/integration/test_m3_action_authorization_atomicity.py +++ b/tests/integration/test_m3_action_authorization_atomicity.py @@ -12,7 +12,12 @@ import pytest from sqlalchemy import func, select -from tests.atomicity.harness import assert_phase_delta, failed_command_allowances, snapshot +from tests.atomicity.harness import ( + assert_phase_delta, + failed_command_allowances, + snapshot, + unchanged_or_operation_owner, +) from tests.integration.scoped_runtime import create_runtime from tests.integration.storage_coverage_helpers import prepare_project, request, value from tests.integration.test_a04_storage_authority import ( @@ -313,7 +318,10 @@ def observe(connection: Any, table_name: str, identity: dict[str, Any]) -> None: ) external = snapshot(runtime.ledger.engine) external_changes = sorted( - name for name in before if name not in BOOKKEEPING and external[name] != before[name] + name + for name in before + if name not in BOOKKEEPING + and not unchanged_or_operation_owner(before, external, name, command) ) staged: list[dict[str, Any]] = [] for participant in state["participants"]: @@ -478,7 +486,11 @@ async def test_authorization_post_write_failure_is_atomic( "observed_resource_uses": actual_reads, "actual_callable": binding, "fault": state, - "changed_tables": sorted(name for name in before if before[name] != after[name]), + "changed_tables": sorted( + name + for name in before + if not unchanged_or_operation_owner(before, after, name, command) + ), "execution_rows_before_after": [ counts_before, (row_count(runtime, plan_executions), row_count(runtime, step_execution_attempts)), @@ -487,9 +499,11 @@ async def test_authorization_post_write_failure_is_atomic( assert len(state["hits"]) == 1, "HOLD_CONTRACT_MISMATCH: expected target hit1" assert not state["violations"], ("HOLD_DEFECT", state["violations"]) assert failed.error is not None, "HOLD_DEFECT: post-write failure reported success" - assert all(before[t] == after[t] for t in before if t not in BOOKKEEPING), ( - "HOLD_DEFECT: failed mutation published domain rows" - ) + assert all( + unchanged_or_operation_owner(before, after, t, command) + for t in before + if t not in BOOKKEEPING + ), "HOLD_DEFECT: failed mutation published domain rows" assert evidence["execution_rows_before_after"][1] == counts_before assert actual_reads == [(ref, "READ") for ref in reads], ( "HOLD_CONTRACT_MISMATCH: source-defined read-set differs", diff --git a/tests/integration/test_m3_action_mutation_atomicity.py b/tests/integration/test_m3_action_mutation_atomicity.py index 76f4b8c..adf6f50 100644 --- a/tests/integration/test_m3_action_mutation_atomicity.py +++ b/tests/integration/test_m3_action_mutation_atomicity.py @@ -8,7 +8,12 @@ import pytest from sqlalchemy import event, select -from tests.atomicity.harness import assert_phase_delta, failed_command_allowances, snapshot +from tests.atomicity.harness import ( + assert_phase_delta, + failed_command_allowances, + snapshot, + unchanged_or_operation_owner, +) from tests.integration.scoped_runtime import create_runtime from tests.integration.storage_coverage_helpers import request, value from tests.integration.test_a03_storage_atomicity import setup @@ -463,7 +468,10 @@ def observe(connection: Any, table_name: str, identity: dict[str, Any]) -> None: ) external = snapshot(runtime.ledger.engine) external_changes = sorted( - name for name in before if name not in BOOKKEEPING and external[name] != before[name] + name + for name in before + if name not in BOOKKEEPING + and not unchanged_or_operation_owner(before, external, name, command) ) staged: list[dict[str, Any]] = [] for participant in state["participants"]: @@ -799,14 +807,20 @@ async def test_action_post_write_failure_is_atomic( "observed_resource_uses": actual_reads, "actual_callable": binding, "fault": state, - "changed_tables": sorted(name for name in before if before[name] != after[name]), + "changed_tables": sorted( + name + for name in before + if not unchanged_or_operation_owner(before, after, name, command) + ), } assert len(state["hits"]) == 1, "HOLD_CONTRACT_MISMATCH: expected target hit1" assert not state["violations"], ("HOLD_DEFECT", state["violations"]) assert failed.error is not None, "HOLD_DEFECT: post-write failure reported success" - assert all(before[t] == after[t] for t in before if t not in BOOKKEEPING), ( - "HOLD_DEFECT: failed mutation published domain rows" - ) + assert all( + unchanged_or_operation_owner(before, after, t, command) + for t in before + if t not in BOOKKEEPING + ), "HOLD_DEFECT: failed mutation published domain rows" assert actual_reads == [(ref, "READ") for ref in reads], ( "HOLD_CONTRACT_MISMATCH: source-defined read-set differs", reads, diff --git a/tests/integration/test_m3_execution_effect_atomicity.py b/tests/integration/test_m3_execution_effect_atomicity.py index 5c6d58f..ce94603 100644 --- a/tests/integration/test_m3_execution_effect_atomicity.py +++ b/tests/integration/test_m3_execution_effect_atomicity.py @@ -21,7 +21,12 @@ import pytest from sqlalchemy import func, select -from tests.atomicity.harness import assert_phase_delta, failed_command_allowances, snapshot +from tests.atomicity.harness import ( + assert_phase_delta, + failed_command_allowances, + snapshot, + unchanged_or_operation_owner, +) from tests.integration.scoped_runtime import create_runtime from tests.integration.storage_coverage_helpers import request, value from tests.integration.test_m3_execution_revision_atomicity import ( @@ -330,7 +335,10 @@ def observe(connection: Any, table_name: str, identity: dict[str, Any]) -> None: ) external = snapshot(runtime.ledger.engine) external_changes = sorted( - name for name in before if name not in BOOKKEEPING and external[name] != before[name] + name + for name in before + if name not in BOOKKEEPING + and not unchanged_or_operation_owner(before, external, name, command) ) staged: list[dict[str, Any]] = [] # 2026-10-15 test-side correction: SqliteLedgerTransaction.set_head (sqlite.py:122-141) @@ -545,7 +553,11 @@ async def test_execution_effect_post_write_failure_is_atomic( "expected_writer_order": [list(step) for step in expected_order], "observed_writer_order": [list(step) for step in state["order"]], "fault": state, - "changed_tables": sorted(name for name in before if before[name] != after[name]), + "changed_tables": sorted( + name + for name in before + if not unchanged_or_operation_owner(before, after, name, command) + ), } assert len(state["hits"]) == 1, "HOLD_CONTRACT_MISMATCH: expected target hit1" assert state["order"] == expected_order, ( @@ -555,9 +567,11 @@ async def test_execution_effect_post_write_failure_is_atomic( ) assert not state["violations"], ("HOLD_DEFECT", state["violations"]) assert failed.error is not None, "HOLD_DEFECT: post-write failure reported success" - assert all(before[t] == after[t] for t in before if t not in BOOKKEEPING), ( - "HOLD_DEFECT: failed mutation published domain rows" - ) + assert all( + unchanged_or_operation_owner(before, after, t, command) + for t in before + if t not in BOOKKEEPING + ), "HOLD_DEFECT: failed mutation published domain rows" assert actual_reads == [(ref, "READ") for ref in reads], ( "HOLD_CONTRACT_MISMATCH: source-defined read-set differs", reads, diff --git a/tests/integration/test_m3_execution_revision_atomicity.py b/tests/integration/test_m3_execution_revision_atomicity.py index 113a9f9..ec9e6e3 100644 --- a/tests/integration/test_m3_execution_revision_atomicity.py +++ b/tests/integration/test_m3_execution_revision_atomicity.py @@ -16,7 +16,12 @@ import pytest from sqlalchemy import select -from tests.atomicity.harness import assert_phase_delta, failed_command_allowances, snapshot +from tests.atomicity.harness import ( + assert_phase_delta, + failed_command_allowances, + snapshot, + unchanged_or_operation_owner, +) from tests.integration.scoped_runtime import create_runtime from tests.integration.storage_coverage_helpers import request, value from tests.integration.test_a03_storage_atomicity import setup @@ -261,7 +266,10 @@ def observe(connection: Any, table_name: str, identity: dict[str, Any]) -> None: ) external = snapshot(runtime.ledger.engine) external_changes = sorted( - name for name in before if name not in BOOKKEEPING and external[name] != before[name] + name + for name in before + if name not in BOOKKEEPING + and not unchanged_or_operation_owner(before, external, name, command) ) staged: list[dict[str, Any]] = [] for participant in state["participants"]: @@ -414,14 +422,20 @@ async def test_execution_post_write_failure_is_atomic( "observed_resource_uses": actual_reads, "actual_callable": binding, "fault": state, - "changed_tables": sorted(name for name in before if before[name] != after[name]), + "changed_tables": sorted( + name + for name in before + if not unchanged_or_operation_owner(before, after, name, command) + ), } assert len(state["hits"]) == 1, "HOLD_CONTRACT_MISMATCH: expected target hit1" assert not state["violations"], ("HOLD_DEFECT", state["violations"]) assert failed.error is not None, "HOLD_DEFECT: post-write failure reported success" - assert all(before[t] == after[t] for t in before if t not in BOOKKEEPING), ( - "HOLD_DEFECT: failed mutation published domain rows" - ) + assert all( + unchanged_or_operation_owner(before, after, t, command) + for t in before + if t not in BOOKKEEPING + ), "HOLD_DEFECT: failed mutation published domain rows" assert actual_reads == [(ref, "READ") for ref in reads], ( "HOLD_CONTRACT_MISMATCH: source-defined read-set differs", reads, diff --git a/tests/integration/test_m3_object_mutation_atomicity.py b/tests/integration/test_m3_object_mutation_atomicity.py index 0139acd..e87d4f6 100644 --- a/tests/integration/test_m3_object_mutation_atomicity.py +++ b/tests/integration/test_m3_object_mutation_atomicity.py @@ -8,7 +8,12 @@ import pytest from sqlalchemy import event, select -from tests.atomicity.harness import assert_phase_delta, failed_command_allowances, snapshot +from tests.atomicity.harness import ( + assert_phase_delta, + failed_command_allowances, + snapshot, + unchanged_or_operation_owner, +) from tests.integration.scoped_runtime import create_runtime from tests.integration.storage_coverage_helpers import request, value from tests.integration.test_a03_storage_atomicity import setup @@ -407,7 +412,10 @@ def observe(connection: Any, table_name: str, identity: dict[str, Any]) -> None: ) external = snapshot(runtime.ledger.engine) external_changes = { - t: external[t] for t in before if t not in BOOKKEEPING and external[t] != before[t] + t: external[t] + for t in before + if t not in BOOKKEEPING + and not unchanged_or_operation_owner(before, external, t, command) } staged: list[dict[str, Any]] = [] for participant in state["participants"]: @@ -629,9 +637,11 @@ async def test_mutation_post_write_failure_is_atomic( if kind in {"split", "merge"}: assert state["selected_audit"]["payload"]["applied"] is False assert failed.error is not None, "HOLD_DEFECT: post-write failure reported success" - assert all(before[t] == after[t] for t in before if t not in BOOKKEEPING), ( - "HOLD_DEFECT: failed mutation published domain rows" - ) + assert all( + unchanged_or_operation_owner(before, after, t, command) + for t in before + if t not in BOOKKEEPING + ), "HOLD_DEFECT: failed mutation published domain rows" assert actual_reads == [(ref, "READ") for ref in reads], ( "HOLD_CONTRACT_MISMATCH: source-defined read-set differs", reads, diff --git a/tests/integration/test_m3_outcome_child_atomicity.py b/tests/integration/test_m3_outcome_child_atomicity.py index ffd38e0..b999770 100644 --- a/tests/integration/test_m3_outcome_child_atomicity.py +++ b/tests/integration/test_m3_outcome_child_atomicity.py @@ -27,7 +27,12 @@ import pytest from sqlalchemy import Connection, event, func, select -from tests.atomicity.harness import assert_phase_delta, failed_command_allowances, snapshot +from tests.atomicity.harness import ( + assert_phase_delta, + failed_command_allowances, + snapshot, + unchanged_or_operation_owner, +) from tests.atomicity.outcome_helpers import prepare_outcome from tests.integration.scoped_runtime import create_runtime from tests.integration.storage_coverage_helpers import request, value @@ -252,7 +257,10 @@ def observe(connection: Any, table_name: str, identity: dict[str, Any]) -> None: ) external = snapshot(runtime.ledger.engine) external_changes = sorted( - name for name in before if name not in BOOKKEEPING and external[name] != before[name] + name + for name in before + if name not in BOOKKEEPING + and not unchanged_or_operation_owner(before, external, name, command) ) staged: list[dict[str, Any]] = [] # 2026-10-15 (see 92b): set_head updates the working_heads row of an aggregate in place, @@ -471,7 +479,11 @@ async def test_outcome_child_post_write_failure_is_atomic( "expected_writer_order": [list(step) for step in expected_order], "observed_writer_order": [list(step) for step in state["order"]], "fault": state, - "changed_tables": sorted(name for name in before if before[name] != after[name]), + "changed_tables": sorted( + name + for name in before + if not unchanged_or_operation_owner(before, after, name, command) + ), } assert len(state["hits"]) == 1, "HOLD_CONTRACT_MISMATCH: expected target hit1" assert state["order"] == expected_order, ( @@ -482,9 +494,11 @@ async def test_outcome_child_post_write_failure_is_atomic( assert not state["violations"], ("HOLD_DEFECT", state["violations"]) assert failed.error is not None, "HOLD_DEFECT: post-write failure reported success" # Every non-bookkeeping table, Execution tables included, equals the state before. - assert all(before[t] == after[t] for t in before if t not in BOOKKEEPING), ( - "HOLD_DEFECT: failed mutation published domain rows" - ) + assert all( + unchanged_or_operation_owner(before, after, t, command) + for t in before + if t not in BOOKKEEPING + ), "HOLD_DEFECT: failed mutation published domain rows" assert actual_reads == [(ref, "READ") for ref in reads], ( "HOLD_CONTRACT_MISMATCH: source-defined read-set differs", reads, @@ -530,6 +544,7 @@ def statement( engine = runtime.ledger.engine try: + command = request(method, "94b-followup-normal", payload) before = snapshot(engine) event.listen(engine, "before_cursor_execute", statement) try: @@ -547,12 +562,20 @@ def statement( "INSERT INTO CHECKPOINTS", "UPDATE CHECKPOINTS SET", } + assert unchanged_or_operation_owner(before, after, "operation_owners", command) + owner_count = len(after.get("operation_owners", ())) - len( + before.get("operation_owners", ()) + ) + assert statements.count("INSERT INTO OPERATION_OWNERS") == owner_count + if owner_count: + bus_tables.add("INSERT INTO OPERATION_OWNERS") domain = sorted(item for item in statements if item not in bus_tables) assert domain == ["INSERT INTO OUTCOME_AUDIT"], domain changed = sorted( name for name in before - if name not in BOOKKEEPING | {"checkpoints"} and before[name] != after[name] + if name not in BOOKKEEPING | {"checkpoints"} + and not unchanged_or_operation_owner(before, after, name, command) ) assert changed == ["outcome_audit"], changed assert "digest" in result["candidates"] @@ -592,9 +615,11 @@ def after_sql( after = snapshot(runtime.ledger.engine) assert hits == [True], hits assert failed.error is not None - assert all(before[t] == after[t] for t in before if t not in BOOKKEEPING), ( - "HOLD_DEFECT: failed follow-up published domain rows" - ) + assert all( + unchanged_or_operation_owner(before, after, t, command) + for t in before + if t not in BOOKKEEPING + ), "HOLD_DEFECT: failed follow-up published domain rows" assert_phase_delta( before, after, diff --git a/tests/integration/test_m3_outcome_revision_atomicity.py b/tests/integration/test_m3_outcome_revision_atomicity.py index 809a34e..03c7fbd 100644 --- a/tests/integration/test_m3_outcome_revision_atomicity.py +++ b/tests/integration/test_m3_outcome_revision_atomicity.py @@ -21,7 +21,12 @@ import pytest from sqlalchemy import func, select -from tests.atomicity.harness import assert_phase_delta, failed_command_allowances, snapshot +from tests.atomicity.harness import ( + assert_phase_delta, + failed_command_allowances, + snapshot, + unchanged_or_operation_owner, +) from tests.atomicity.outcome_helpers import prepare_outcome from tests.integration.scoped_runtime import create_runtime from tests.integration.storage_coverage_helpers import request, value @@ -271,7 +276,10 @@ def observe(connection: Any, table_name: str, identity: dict[str, Any]) -> None: ) external = snapshot(runtime.ledger.engine) external_changes = sorted( - name for name in before if name not in BOOKKEEPING and external[name] != before[name] + name + for name in before + if name not in BOOKKEEPING + and not unchanged_or_operation_owner(before, external, name, command) ) staged: list[dict[str, Any]] = [] # 2026-10-15 (see 92b): set_head updates the working_heads row of an aggregate in place, @@ -460,7 +468,11 @@ async def test_outcome_revision_post_write_failure_is_atomic( "expected_writer_order": [list(step) for step in expected_order], "observed_writer_order": [list(step) for step in state["order"]], "fault": state, - "changed_tables": sorted(name for name in before if before[name] != after[name]), + "changed_tables": sorted( + name + for name in before + if not unchanged_or_operation_owner(before, after, name, command) + ), } assert len(state["hits"]) == 1, "HOLD_CONTRACT_MISMATCH: expected target hit1" assert state["order"] == expected_order, ( @@ -471,9 +483,11 @@ async def test_outcome_revision_post_write_failure_is_atomic( assert not state["violations"], ("HOLD_DEFECT", state["violations"]) assert failed.error is not None, "HOLD_DEFECT: post-write failure reported success" # Every non-bookkeeping table, Execution tables included, equals the state before. - assert all(before[t] == after[t] for t in before if t not in BOOKKEEPING), ( - "HOLD_DEFECT: failed mutation published domain rows" - ) + assert all( + unchanged_or_operation_owner(before, after, t, command) + for t in before + if t not in BOOKKEEPING + ), "HOLD_DEFECT: failed mutation published domain rows" assert actual_reads == [(ref, "READ") for ref in reads], ( "HOLD_CONTRACT_MISMATCH: source-defined read-set differs", reads, diff --git a/tests/integration/test_m4_memory_control_multiwrite_observation.py b/tests/integration/test_m4_memory_control_multiwrite_observation.py index 162c8a9..4f2b110 100644 --- a/tests/integration/test_m4_memory_control_multiwrite_observation.py +++ b/tests/integration/test_m4_memory_control_multiwrite_observation.py @@ -27,7 +27,7 @@ import pytest from sqlalchemy import select -from tests.atomicity.harness import snapshot +from tests.atomicity.harness import snapshot, unchanged_or_operation_owner from tests.integration.storage_coverage_helpers import request, value from thoth.adapters.runtime import SystemClock, UuidIdGenerator @@ -104,7 +104,12 @@ def table_changes(before: Any, after: Any) -> dict[str, dict[str, int]]: return result -def domain_changes(changes: dict[str, dict[str, int]]) -> dict[str, dict[str, int]]: +def domain_changes( + changes: dict[str, dict[str, int]], before: Any, after: Any, command: Any +) -> dict[str, dict[str, int]]: + if "operation_owners" in changes: + assert unchanged_or_operation_owner(before, after, "operation_owners", command) + changes = {name: delta for name, delta in changes.items() if name != "operation_owners"} return {name: delta for name, delta in changes.items() if name not in BOOKKEEPING} @@ -271,9 +276,15 @@ async def test_control_route_success_writes_the_two_rows_in_source_order( await call(runtime, method, f"step0-{route}-ok", payload) added = rows(runtime)[len(before_rows) :] assert [(item["type"]) for item in added] == [t for t, _ in EXPECTED_ORDER[route]] - assert set(domain_changes(table_changes(before, snapshot(runtime.ledger.engine)))) == { - "control_records" - } + after = snapshot(runtime.ledger.engine) + assert set( + domain_changes( + table_changes(before, after), + before, + after, + request(method, f"step0-{route}-ok", payload), + ) + ) == {"control_records"} save( f"{route}-success", { @@ -306,13 +317,16 @@ async def test_second_control_write_failure_leaves_the_first_row_and_a_failed_op assert failed.error is not None assert [item["type"] for item in hits] == [t for t, _ in EXPECTED_ORDER[route]] rows_after = rows(runtime) - changes = table_changes(before, snapshot(runtime.ledger.engine)) + fault_after = snapshot(runtime.ledger.engine) + changes = table_changes(before, fault_after) added = rows_after[len(rows_before) :] expected_added = 1 if mode == "before" else 2 assert [item["type"] for item in added] == [t for t, _ in EXPECTED_ORDER[route]][ :expected_added ] - assert set(domain_changes(changes)) == {"control_records"} + assert set(domain_changes(changes, before, fault_after, request(method, key, payload))) == { + "control_records" + } reads_after_fault = await later_reads(runtime, route, candidate_id, "fault") runtime.close() @@ -350,7 +364,9 @@ async def test_second_control_write_failure_leaves_the_first_row_and_a_failed_op "hits_in_order": hits, "error": failed.error.model_dump(mode="json"), "rows_added_by_failed_request": added, - "domain_tables_changed_by_failed_request": domain_changes(changes), + "domain_tables_changed_by_failed_request": domain_changes( + changes, before, fault_after, request(method, key, payload) + ), "later_reads_before": reads_before, "later_reads_after_fault": reads_after_fault, "reopened_rows_equal": True, diff --git a/tests/integration/test_operation_owner_atomicity.py b/tests/integration/test_operation_owner_atomicity.py new file mode 100644 index 0000000..51f45f8 --- /dev/null +++ b/tests/integration/test_operation_owner_atomicity.py @@ -0,0 +1,98 @@ +"""Two sealers and injected durable-event failure use one terminal transaction.""" + +import asyncio +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from threading import Barrier + +import pytest +from tests.integration.scoped_runtime import create_runtime +from tests.integration.test_operation_owner_recovery import abandoned, terminal_counts + +from thoth.adapters.storage.events import SqliteEventStore +from thoth.adapters.storage.operations import SqliteOperationStore +from thoth.adapters.storage.transaction import _AMBIENT # pyright: ignore[reportPrivateUsage] +from thoth.adapters.worker_identity import LocalWorkerIdentity +from thoth.domain.event import EventRecord +from thoth.protocol.jsonrpc import JsonRpcRequest + + +async def test_two_sealers_publish_one_failure_and_one_measurement( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + first, command, identifier = await abandoned(tmp_path) + second = create_runtime(tmp_path / "workspace") + original = SqliteOperationStore(first.ledger.engine).read(identifier) + assert original is not None and original.owner is not None + stale_worker = original.owner.worker_id + barrier = Barrier(2) + alive = LocalWorkerIdentity.is_alive + observations: list[bool | None] = [] + measurements: list[str] = [] + + def observed(self: LocalWorkerIdentity, pid: int, marker: str, worker: str) -> bool | None: + assert _AMBIENT.get() is None + result = alive(self, pid, marker, worker) + if worker == stale_worker: + observations.append(result) + barrier.wait(timeout=10) + return result + + def measured(request: JsonRpcRequest, project: str, outcome: str) -> None: + del request, project + assert _AMBIENT.get() is None + measurements.append(outcome) + + monkeypatch.setattr(LocalWorkerIdentity, "is_alive", observed) + monkeypatch.setattr(first.bus, "_measure", measured) + monkeypatch.setattr(second.bus, "_measure", measured) + try: + with ThreadPoolExecutor(max_workers=2) as executor: + a = executor.submit(asyncio.run, first.bus.dispatch(command)) + b = executor.submit(asyncio.run, second.bus.dispatch(command)) + replies = [a.result(timeout=15), b.result(timeout=15)] + assert observations == [False, False] + assert replies[0] == replies[1] and replies[0].error is not None + assert measurements == ["FAILED"] and terminal_counts(first, identifier) == (1, 1) + stored = SqliteOperationStore(first.ledger.engine).read(identifier) + assert stored is not None and stored.error == replies[0].error.model_dump(mode="json") + assert stored.resource_uses is not None + finally: + second.close() + first.close() + + +async def test_event_insert_failure_rolls_back_failure_binding_and_event( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + runtime, command, identifier = await abandoned(tmp_path) + store = SqliteOperationStore(runtime.ledger.engine) + events = SqliteEventStore(runtime.ledger.engine) + before, before_events = store.read(identifier), events.list_events(identifier) + measurements: list[str] = [] + insert = SqliteEventStore.insert_event + + def injected(self: SqliteEventStore, event: EventRecord) -> None: + insert(self, event) + if event.operation_id == identifier and event.event_type == "operation.failed": + assert _AMBIENT.get() is not None + raise RuntimeError("injected after durable failure event") + + def measured(request: JsonRpcRequest, project: str, outcome: str) -> None: + del request, project + measurements.append(outcome) + + monkeypatch.setattr(runtime.bus, "_measure", measured) + try: + with monkeypatch.context() as patch: + patch.setattr(SqliteEventStore, "insert_event", injected) + with pytest.raises(RuntimeError, match="injected after durable failure event"): + await runtime.bus.dispatch(command) + assert store.read(identifier) == before + assert events.list_events(identifier) == before_events + assert terminal_counts(runtime, identifier) == (0, 0) and measurements == [] + reply = await runtime.bus.dispatch(command) + assert reply.error is not None and reply.error.data["reason_code"] == "OPERATION_OWNER_GONE" + assert terminal_counts(runtime, identifier) == (1, 1) and measurements == ["FAILED"] + finally: + runtime.close() diff --git a/tests/integration/test_operation_owner_completion_race.py b/tests/integration/test_operation_owner_completion_race.py new file mode 100644 index 0000000..8a2d9c0 --- /dev/null +++ b/tests/integration/test_operation_owner_completion_race.py @@ -0,0 +1,177 @@ +"""A real completion must replay the sealing winner, never announce a false success.""" + +import asyncio +import json +from collections.abc import Callable, Generator +from concurrent.futures import ThreadPoolExecutor +from contextlib import AbstractContextManager, contextmanager +from datetime import datetime +from pathlib import Path +from threading import Event +from typing import cast + +import pytest +from pydantic import JsonValue +from tests.integration.scoped_runtime import create_runtime +from tests.integration.storage_coverage_helpers import prepare_project, request + +from thoth.adapters.storage.events import SqliteEventStore +from thoth.adapters.storage.operations import SqliteOperationStore +from thoth.domain.enums import OperationState +from thoth.domain.operation import OperationRecord +from thoth.protocol.bus import DispatchTicket +from thoth.protocol.jsonrpc import RpcApplicationError, RpcErrorCode + + +@pytest.mark.parametrize( + ("outcome", "winner"), + [(name, "seal") for name in ["success", "rpc-error", "permission", "validation", "internal"]] + + [("success", "completion")], +) +async def test_sealing_winner_is_replayed_by_original_completion( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + record_property: Callable[[str, object], None], + outcome: str, + winner: str, +) -> None: + workspace = tmp_path / "workspace" + original, project = await prepare_project(workspace) + command = request( + "project/metadata/update", + "complete-race", + { + "project_id": project, + "expected_revision": 0, + "name": "Saved once", + }, + ) + if outcome != "success": + + async def rejected(values: dict[str, JsonValue]) -> dict[str, JsonValue]: + del values + if outcome == "rpc-error": + raise RpcApplicationError(RpcErrorCode.DOMAIN_REJECTED, "synthetic rejection") + if outcome == "permission": + raise PermissionError("SYNTHETIC_AUTHORITY_REJECTED") + if outcome == "validation": + OperationRecord.model_validate({}) + raise RuntimeError("synthetic internal failure") + + original.bus._registry.decorate("project/metadata/update", lambda handler: rejected) # pyright: ignore[reportPrivateUsage] + ticket = original.bus.claim(command) + assert isinstance(ticket, DispatchTicket) and ticket.operation.owner is not None + identifier = ticket.operation.operation_id + before_complete, resume_complete = Event(), Event() + complete = SqliteOperationStore.complete + fail = SqliteOperationStore.fail + + def paused_complete( + self: SqliteOperationStore, + operation_id: str, + result: dict[str, JsonValue], + *, + completed_at: datetime, + ) -> OperationRecord: + if operation_id == identifier: + before_complete.set() + assert resume_complete.wait(timeout=10), "completion gate was not released" + return complete(self, operation_id, result, completed_at=completed_at) + + monkeypatch.setattr(SqliteOperationStore, "complete", paused_complete) + + def paused_fail( + self: SqliteOperationStore, + operation_id: str, + error: dict[str, JsonValue], + *, + completed_at: datetime, + ) -> OperationRecord: + if operation_id == identifier and self._engine is original.ledger.engine: # pyright: ignore[reportPrivateUsage] + before_complete.set() + assert resume_complete.wait(timeout=10), "failure gate was not released" + return fail(self, operation_id, error, completed_at=completed_at) + + monkeypatch.setattr(SqliteOperationStore, "fail", paused_fail) + peer = None + closed = False + pin_reached, allow_pin = Event(), Event() + with ThreadPoolExecutor(max_workers=2) as executor: + future = executor.submit(asyncio.run, original.bus.execute(ticket)) + try: + assert before_complete.wait(timeout=10), "original handler never reached completion" + # close() retires this runtime's worker while its general completion is waiting. + original.close() + closed = True + peer = create_runtime(workspace) + if winner == "completion": + policy = vars(peer.bus)["_owner"] + original_pin = cast( + Callable[[], AbstractContextManager[bool]], vars(policy)["_pin_workspace"] + ) + + @contextmanager + def gated_pin() -> Generator[bool]: + with original_pin() as held: + pin_reached.set() + assert allow_pin.wait(timeout=10) + yield held + + monkeypatch.setattr(policy, "_pin_workspace", gated_pin) + sealing = executor.submit(asyncio.run, peer.bus.dispatch(command)) + assert pin_reached.wait(timeout=10) + resume_complete.set() + completed = future.result(timeout=10) + allow_pin.set() + sealed = sealing.result(timeout=10) + else: + sealed = await peer.bus.dispatch(command) + assert sealed.error is not None + assert sealed.error.data["reason_code"] == "OPERATION_OWNER_GONE" + resume_complete.set() + completed = future.result(timeout=10) + stored = SqliteOperationStore(peer.ledger.engine).read(identifier) + events = SqliteEventStore(peer.ledger.engine).list_events(identifier) + observed = { + "original_outcome": outcome, + "winner": winner, + "stored_state": None if stored is None else stored.state.value, + "sealing_response_error": sealed.error is not None, + "original_response_error": completed.error is not None, + "original_result": completed.result, + "events": [item.event_type for item in events], + } + record_property("race_observation", json.dumps(observed)) + print(json.dumps(observed)) + assert completed == sealed and stored is not None + with peer.ledger.engine.connect() as connection: + binding = json.loads( + connection.exec_driver_sql( + "SELECT content_json FROM operation_resource_bindings WHERE operation_id=?", + (identifier,), + ).scalar_one() + ) + if winner == "completion": + assert completed.error is None and stored.state is OperationState.SUCCEEDED + assert binding["output_kind"] == "RESULT" + assert [item.event_type for item in events] == [ + "operation.started", + "project/metadata/updated", + "operation.succeeded", + ] + else: + assert sealed.error is not None + assert stored.error == sealed.error.model_dump(mode="json") + assert binding["output_kind"] == "ERROR" + assert [item.event_type for item in events] == [ + "operation.started", + "operation.failed", + ] + finally: + allow_pin.set() + resume_complete.set() + future.result(timeout=10) + if peer is not None: + peer.close() + if not closed: + original.close() diff --git a/tests/integration/test_operation_owner_migration.py b/tests/integration/test_operation_owner_migration.py new file mode 100644 index 0000000..6e38225 --- /dev/null +++ b/tests/integration/test_operation_owner_migration.py @@ -0,0 +1,118 @@ +"""Old RUNNING operations stay ownerless; new DDL is one additive table.""" + +import hashlib +import json +import sqlite3 +from contextlib import closing +from pathlib import Path + +import pytest +from alembic import command +from alembic.config import Config +from sqlalchemy import create_engine, insert +from tests.integration.scoped_runtime import create_runtime +from tests.integration.test_operation_owner_storage import candidate, owner + +from thoth.adapters.storage.operations import SqliteOperationStore +from thoth.adapters.storage.schema import idempotency_keys, operations +from thoth.domain.enums import OperationState +from thoth.protocol.registry import PUBLIC_METHODS + + +def test_upgrade_keeps_old_rows_and_reopen_roundtrip( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + database = tmp_path / "old.sqlite3" + url = "sqlite+pysqlite:///" + database.as_posix() + monkeypatch.setenv("THOTH_ALEMBIC_URL", url) + config = Config("alembic.ini") + command.upgrade(config, "3b06e4f87d92") + original = candidate() + engine = create_engine(url) + with engine.begin() as connection: + connection.execute( + insert(operations).values( + operation_id=original.operation_id, + project_id=original.project_id, + method=original.method, + idempotency_key=original.idempotency_key, + scope_digest=original.scope_digest, + state="RUNNING", + created_at=original.created_at.isoformat(), + epoch=0, + owner_data_scopes_json="[]", + ) + ) + connection.execute( + insert(idempotency_keys).values( + project_id=original.project_id, + method=original.method, + idempotency_key=original.idempotency_key, + scope_digest=original.scope_digest, + operation_id=original.operation_id, + ) + ) + engine.dispose() + with closing(sqlite3.connect(database)) as connection: + before = connection.execute("SELECT * FROM operations").fetchall() + columns = connection.execute("PRAGMA table_info(operations)").fetchall() + command.upgrade(config, "head") + command.upgrade(config, "head") + engine = create_engine(url) + try: + store = SqliteOperationStore(engine) + stored = store.read(original.operation_id) + assert stored is not None and stored == original and stored.owner is None + assert store.claim(original.model_copy(update={"owner": owner()})) == (original, True) + replayed = store.read(original.operation_id) + assert replayed is not None and replayed.state is OperationState.RUNNING + with engine.connect() as connection: + assert ( + connection.exec_driver_sql("SELECT count(*) FROM operation_owners").scalar_one() + == 0 + ) + finally: + engine.dispose() + command.downgrade(config, "3b06e4f87d92") + command.upgrade(config, "head") + with closing(sqlite3.connect(database)) as connection: + assert connection.execute("SELECT * FROM operations").fetchall() == before + assert connection.execute("PRAGMA table_info(operations)").fetchall() == columns + assert connection.execute("SELECT count(*) FROM operation_owners").fetchone() == (0,) + + +def test_sealable_fixture_is_the_approved_67_registered_methods(tmp_path: Path) -> None: + fixture = Path(__file__).parents[1] / "fixtures/sealable_methods.json" + record = json.loads(fixture.read_text(encoding="utf-8")) + methods = record["methods"] + assert methods == sorted(set(methods)) and len(methods) == 67 + assert set(methods) <= set(PUBLIC_METHODS) + runtime = create_runtime(tmp_path / "workspace") + try: + for method in methods: + assert callable(runtime.bus._registry.resolve(method)) # pyright: ignore[reportPrivateUsage] + finally: + runtime.close() + assert not set(methods).intersection( + { + "thread/start", + "thread/input", + "thread/steer", + "thread/pause", + "thread/resume", + "thread/stop", + "revision/restore", + "revision/restore/apply", + "revision/changeSet/commit", + "project/archive", + "project/delete", + "project/restore", + "workspace/setup/update", + "improvement/evaluation/run", + "action/preflight/read", + } + ) + digest = hashlib.sha256(("\n".join(methods) + "\n").encode()).hexdigest() + assert digest == "83993c93cbe89314247caa791d292cbc806a73d0c955d424d8fcf48ebeb18da4" + assert record["evidence"]["approved_methods_sha256"] == digest + assert record["evidence"]["path"] == "outputs/refactor-vs2-20261019/sealable-evidence.json" diff --git a/tests/integration/test_operation_owner_recovery.py b/tests/integration/test_operation_owner_recovery.py new file mode 100644 index 0000000..a84a93a --- /dev/null +++ b/tests/integration/test_operation_owner_recovery.py @@ -0,0 +1,169 @@ +"""Normal LOCAL entry: dead workers seal, unknown/live/old rows remain untouched.""" + +from pathlib import Path +from typing import Any + +import pytest +from tests.integration.scoped_runtime import create_runtime +from tests.integration.storage_coverage_helpers import prepare_project, request, value + +from thoth.adapters.storage.events import SqliteEventStore +from thoth.adapters.storage.operations import SqliteOperationStore +from thoth.adapters.storage.transaction import _AMBIENT # pyright: ignore[reportPrivateUsage] +from thoth.apps.runtime import AppRuntime +from thoth.domain.enums import OperationState +from thoth.domain.resource_scope import ResourceScopeError +from thoth.protocol.bus import DispatchTicket +from thoth.protocol.jsonrpc import JsonRpcRequest + + +async def abandoned(tmp_path: Path) -> tuple[AppRuntime, JsonRpcRequest, str]: + workspace = tmp_path / "workspace" + first, project = await prepare_project(workspace) + command = request( + "project/metadata/update", + "abandoned", + { + "project_id": project, + "expected_revision": 0, + "name": "Never executed", + }, + ) + claimed = first.bus.claim(command) + assert isinstance(claimed, DispatchTicket) and claimed.operation.owner is not None + first.close() + return create_runtime(workspace), command, claimed.operation.operation_id + + +def terminal_counts(runtime: AppRuntime, identifier: str) -> tuple[int, int]: + with runtime.ledger.engine.connect() as connection: + events = connection.exec_driver_sql( + "SELECT count(*) FROM events WHERE operation_id=? AND event_type='operation.failed'", + (identifier,), + ).scalar_one() + bindings = connection.exec_driver_sql( + "SELECT count(*) FROM operation_resource_bindings WHERE operation_id=?", + (identifier,), + ).scalar_one() + return events, bindings + + +async def test_closed_runtime_same_pid_seals_once_then_replays( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + runtime, command, identifier = await abandoned(tmp_path) + measurements: list[str] = [] + + def measured(request: JsonRpcRequest, project: str, outcome: str) -> None: + del request, project + assert _AMBIENT.get() is None + stored = SqliteOperationStore(runtime.ledger.engine).read(identifier) + assert stored is not None and stored.state is OperationState.FAILED + assert terminal_counts(runtime, identifier) == (1, 1) + measurements.append(outcome) + + monkeypatch.setattr(runtime.bus, "_measure", measured) + try: + first = await runtime.bus.dispatch(command) + second = await runtime.bus.dispatch(command) + assert first == second and first.error is not None + assert first.error.data["reason_code"] == "OPERATION_OWNER_GONE" + assert first.error.data["remote_observation"] == "UNKNOWN" + assert first.error.data["new_request_required"] is True + assert measurements == ["FAILED"] and terminal_counts(runtime, identifier) == (1, 1) + project = value( + await runtime.bus.query( + request( + "project/read", + "read", + { + "project_id": command.params.input["project_id"], + }, + ) + ) + ) + assert project["name"] == "Storage coverage" and project["revision"] == 0 + finally: + runtime.close() + + +@pytest.mark.parametrize("case", ["owner-missing", "host-mismatch", "unknown-liveness"]) +async def test_uncertain_or_old_owner_keeps_running( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, case: str +) -> None: + runtime, command, identifier = await abandoned(tmp_path) + try: + if case == "unknown-liveness": + from thoth.adapters.worker_identity import LocalWorkerIdentity + + def unknown(*args: object) -> None: + return None + + monkeypatch.setattr(LocalWorkerIdentity, "is_alive", unknown) + else: + with runtime.ledger.engine.begin() as connection: + if case == "owner-missing": + connection.exec_driver_sql( + "DELETE FROM operation_owners WHERE operation_id=?", (identifier,) + ) + else: + connection.exec_driver_sql( + "UPDATE operation_owners SET host_token=? WHERE operation_id=?", + ("f" * 64, identifier), + ) + result = await runtime.bus.dispatch(command) + assert result.error is None and result.result is not None + assert result.result["state"] == "RUNNING" and terminal_counts(runtime, identifier) == ( + 0, + 0, + ) + finally: + runtime.close() + + +async def test_live_owner_keeps_running(tmp_path: Path) -> None: + runtime, project = await prepare_project(tmp_path / "workspace") + command = request( + "project/metadata/update", + "live", + {"project_id": project, "expected_revision": 0, "name": "Later"}, + ) + try: + claimed = runtime.bus.claim(command) + assert isinstance(claimed, DispatchTicket) + response = await runtime.bus.dispatch(command) + assert response.error is None and response.result is not None + assert response.result["state"] == "RUNNING" + assert terminal_counts(runtime, claimed.operation.operation_id) == (0, 0) + assert value(await runtime.bus.execute(claimed))["revision"] == 1 + finally: + runtime.close() + + +async def test_authorization_refusal_precedes_any_sealing_write( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from thoth.application.services.resource_scope_service import ResourceScopeService + + runtime, command, identifier = await abandoned(tmp_path) + store = SqliteOperationStore(runtime.ledger.engine) + before = store.read(identifier) + before_events = SqliteEventStore(runtime.ledger.engine).list_events(identifier) + + def denied(self: Any, operation: Any) -> None: + del self + assert operation.operation_id == identifier + raise ResourceScopeError("RESOURCE_SCOPE_READ_DENIED") + + monkeypatch.setattr(ResourceScopeService, "require_operation", denied) + try: + response = await runtime.bus.dispatch(command) + assert ( + response.error is not None + and response.error.data["reason_code"] == "RESOURCE_SCOPE_READ_DENIED" + ) + assert store.read(identifier) == before + assert SqliteEventStore(runtime.ledger.engine).list_events(identifier) == before_events + assert terminal_counts(runtime, identifier) == (0, 0) + finally: + runtime.close() diff --git a/tests/integration/test_operation_owner_storage.py b/tests/integration/test_operation_owner_storage.py new file mode 100644 index 0000000..f4cee7f --- /dev/null +++ b/tests/integration/test_operation_owner_storage.py @@ -0,0 +1,231 @@ +"""Internal ownership is atomic with claim and absent from every public view.""" + +from __future__ import annotations + +from collections.abc import Iterator +from concurrent.futures import ThreadPoolExecutor +from datetime import UTC, datetime +from pathlib import Path +from threading import Barrier +from typing import Any + +import httpx +import pytest +from sqlalchemy import Connection, create_engine, event + +from thoth.adapters.http.app import create_app +from thoth.adapters.runtime import SystemClock, UuidIdGenerator +from thoth.adapters.storage import SqliteLedger, migrate_sqlite_database +from thoth.adapters.storage.events import SqliteEventStore +from thoth.adapters.storage.operations import IdempotencyConflict, SqliteOperationStore +from thoth.application.commands.operations import OperationCommandHandlers +from thoth.application.services.operation_journal import OperationJournal +from thoth.domain.enums import OperationState +from thoth.domain.operation import OperationOwnerRecord, OperationRecord +from thoth.protocol.bus import CommandBus +from thoth.protocol.registry import MethodRegistry + + +def owner(token: str | None = "b" * 64) -> OperationOwnerRecord: + return OperationOwnerRecord( + worker_id="worker:synthetic", + process_id=987654, + process_marker="synthetic-marker", + host_token=token, + ) + + +def candidate(ownership: OperationOwnerRecord | None = None) -> OperationRecord: + return OperationRecord( + operation_id="operation:owner", + project_id="project:owner", + method="project/create", + idempotency_key="owner-key", + scope_digest="a" * 64, + state=OperationState.RUNNING, + created_at=datetime(2026, 10, 19, tzinfo=UTC), + owner=ownership, + ) + + +@pytest.fixture +def ledger(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[SqliteLedger]: + monkeypatch.setenv("THOTH_DEPLOYMENT_MODE", "LOCAL") + monkeypatch.delenv("THOTH_ALEMBIC_URL", raising=False) + database = tmp_path / "test.sqlite3" + migrate_sqlite_database(database) + instance = SqliteLedger(database) + instance.initialize() + try: + yield instance + finally: + instance.close() + + +def counts(ledger: SqliteLedger) -> dict[str, int]: + with ledger.engine.connect() as connection: + return { + name: connection.exec_driver_sql(f"SELECT count(*) FROM {name}").scalar_one() + for name in ("operations", "idempotency_keys", "operation_owners", "events") + } + + +def test_owner_survives_read_terminal_and_duplicate_claim(ledger: SqliteLedger) -> None: + store = SqliteOperationStore(ledger.engine) + original = candidate(owner()) + journal = OperationJournal(SqliteEventStore(ledger.engine), SystemClock(), UuidIdGenerator()) + stored, replayed = store.claim( + original, started_event=lambda: journal.prepare_started(original) + ) + assert stored == original and not replayed + before = counts(ledger) + assert list(before.values()) == [1, 1, 1, 1] + replacement = original.model_copy(update={"owner": owner("c" * 64)}) + replay, replayed = store.claim(replacement) + assert replayed and replay.owner == original.owner and counts(ledger) == before + with pytest.raises(IdempotencyConflict) as conflict: + store.claim(replacement.model_copy(update={"scope_digest": "d" * 64})) + assert conflict.value.existing.owner == original.owner and counts(ledger) == before + assert SqliteOperationStore(ledger.engine).read(original.operation_id) == original + completed = store.complete( + original.operation_id, {"done": True}, completed_at=original.created_at + ) + assert completed.owner == original.owner + assert store.read(original.operation_id) == completed + assert store.list_by_project(original.project_id)[0].owner is None + + +def test_competing_claims_keep_one_owner_and_reopen_it(ledger: SqliteLedger) -> None: + barrier = Barrier(2) + calls: list[str] = [] + + def claim(token: str) -> tuple[OperationRecord, bool]: + engine = create_engine(ledger.engine.url) + original = candidate(owner(token * 64)).model_copy( + update={"operation_id": "operation:" + token} + ) + journal = OperationJournal(SqliteEventStore(engine), SystemClock(), UuidIdGenerator()) + + def started(): + calls.append(token) + return journal.prepare_started(original) + + try: + barrier.wait(timeout=5) + return SqliteOperationStore(engine).claim(original, started_event=started) + finally: + engine.dispose() + + with ThreadPoolExecutor(max_workers=2) as pool: + results = list(pool.map(claim, ("b", "c"))) + assert sorted(replayed for _, replayed in results) == [False, True] + assert results[0][0] == results[1][0] and len(calls) == 1 + assert set(counts(ledger).values()) == {1} + reopened = create_engine(ledger.engine.url) + try: + assert SqliteOperationStore(reopened).read(results[0][0].operation_id) == results[0][0] + finally: + reopened.dispose() + + +@pytest.mark.parametrize("ownership", [None, owner(None)]) +def test_unknown_host_never_records_an_owner( + ledger: SqliteLedger, ownership: OperationOwnerRecord | None +) -> None: + store = SqliteOperationStore(ledger.engine) + stored, replayed = store.claim(candidate(ownership)) + assert not replayed and stored.owner is None + assert counts(ledger)["operation_owners"] == 0 + assert store.read(stored.operation_id) == stored + + +@pytest.mark.parametrize("fault", ["owner_insert", "event_factory", "event_insert"]) +def test_owner_or_started_failure_rolls_back_the_entire_claim( + ledger: SqliteLedger, fault: str +) -> None: + store = SqliteOperationStore(ledger.engine) + original = candidate(owner()) + journal = OperationJournal(SqliteEventStore(ledger.engine), SystemClock(), UuidIdGenerator()) + calls: list[str] = [] + + def inserted( + connection: Connection, + cursor: Any, + statement: str, + parameters: Any, + context: Any, + many: bool, + ) -> None: + del connection, cursor, parameters, context, many + if statement.startswith("INSERT INTO "): + table = statement.split()[2] + calls.append(table) + if (fault == "owner_insert" and table == "operation_owners") or ( + fault == "event_insert" and table == "events" + ): + raise RuntimeError("injected owner boundary failure") + + def started(): + calls.append("started_factory") + if fault == "event_factory": + raise RuntimeError("injected owner boundary failure") + return journal.prepare_started(original) + + event.listen(ledger.engine, "after_cursor_execute", inserted) + try: + with pytest.raises(RuntimeError, match="injected owner boundary failure"): + store.claim(original, started_event=started) + assert calls[:3] == ["operations", "idempotency_keys", "operation_owners"] + assert ("started_factory" in calls) is (fault != "owner_insert") + assert set(counts(ledger).values()) == {0} + finally: + event.remove(ledger.engine, "after_cursor_execute", inserted) + assert ( + store.claim(original, started_event=lambda: journal.prepare_started(original))[1] is False + ) + assert set(counts(ledger).values()) == {1} + + +async def test_get_rpc_and_events_keep_the_previous_public_payload(ledger: SqliteLedger) -> None: + store = SqliteOperationStore(ledger.engine) + original = candidate(owner()) + clock, ids = SystemClock(), UuidIdGenerator() + events = SqliteEventStore(ledger.engine) + journal = OperationJournal(events, clock, ids) + assert journal.prepare_started(original).payload == {"method": original.method} + store.claim(original, started_event=lambda: journal.prepare_started(original)) + registry = MethodRegistry() + registry.register("operation/read", OperationCommandHandlers(store, clock=clock).read) + bus = CommandBus(registry, store, clock, ids) + application = create_app(bus) + baseline = candidate().model_dump(mode="json") + assert original.model_dump(mode="json") == baseline + assert original.model_dump_json() == candidate().model_dump_json() + assert "owner" not in OperationRecord.model_json_schema(mode="serialization")["properties"] + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=application), base_url="http://127.0.0.1:8765" + ) as client: + response = await client.get( + f"/operations/{original.operation_id}", params={"project_id": original.project_id} + ) + assert response.status_code == 200 and response.json() == baseline + response = await client.post( + "/rpc", + json={ + "id": "read", + "method": "operation/read", + "params": { + "_meta": {"idempotencyKey": "read"}, + "input": { + "project_id": original.project_id, + "operation_id": original.operation_id, + }, + }, + }, + ) + assert response.status_code == 200 + assert response.json()["result"]["value"] == baseline + wire = " ".join(item.model_dump_json() for item in events.list_events(original.operation_id)) + for secret in ("synthetic-marker", "worker:synthetic", "987654", "b" * 64): + assert secret not in wire + assert events.list_events(original.operation_id)[0].payload == {"method": original.method} diff --git a/tests/integration/test_resource_scope_transition_boundaries.py b/tests/integration/test_resource_scope_transition_boundaries.py index 47d12f2..5804fbf 100644 --- a/tests/integration/test_resource_scope_transition_boundaries.py +++ b/tests/integration/test_resource_scope_transition_boundaries.py @@ -12,6 +12,7 @@ import pytest from sqlalchemy import Engine, create_engine, delete, select +from tests.atomicity.harness import AllowedRow, assert_phase_delta, operation_owner_allowances from tests.integration.resource_scope_helpers import ScopeHarness, denial, scope_harness, value from tests.integration.test_a02_autonomous_acquisition import StaticModelResolver from tests.integration.test_m5_revision_control_atomicity import NoModel @@ -41,11 +42,15 @@ "checkpoints", "operation_resource_bindings", } -Snapshot = dict[str, tuple[str, ...]] + + +class Snapshot(dict[str, tuple[str, ...]]): + # Original operation rows identify fresh requests; they are not domain projection rows. + operation_rows: tuple[str, ...] = () def snapshot(engine: Engine) -> Snapshot: - result: Snapshot = {} + result = Snapshot() with engine.connect() as connection: names = ( connection.exec_driver_sql( @@ -57,6 +62,15 @@ def snapshot(engine: Engine) -> Snapshot: for name in names: name = str(name) if name in BOOKKEEPING: + if name == "operations": + result.operation_rows = tuple( + sorted( + json.dumps(dict(row), sort_keys=True, default=str) + for row in connection.exec_driver_sql( + "SELECT * FROM operations" + ).mappings() + ) + ) continue quoted = '"' + name.replace('"', '""') + '"' rows = connection.exec_driver_sql("SELECT * FROM " + quoted).mappings() @@ -72,10 +86,42 @@ def snapshot(engine: Engine) -> Snapshot: return result -def reopened(h: ScopeHarness, expected: Snapshot) -> None: +def owner_adjusted( + before: Snapshot, after: Snapshot, project: str, method: str, keys: tuple[str, ...] +) -> dict[str, tuple[str, ...]]: + """Validate one added owner per explicit request; retain every domain row verbatim.""" + rules: list[AllowedRow] = [] + for key in keys: + rows = [ + json.loads(row) + for row in after.operation_rows + if json.loads(row)["project_id"] == project + and json.loads(row)["method"] == method + and json.loads(row)["idempotency_key"] == key + ] + assert len(rows) == 1 + rules.append(AllowedRow("operations", rows[0], "added", {})) + allowances = operation_owner_allowances( + {**before, "operations": before.operation_rows}, + {**after, "operations": after.operation_rows}, + tuple(rules), + ) + assert_phase_delta( + {"operation_owners": before.get("operation_owners", ())}, + {"operation_owners": after.get("operation_owners", ())}, + allowances, + ) + return {**after, "operation_owners": before.get("operation_owners", ())} + + +def reopened( + h: ScopeHarness, expected: Snapshot, *, method: str = "", keys: tuple[str, ...] = () +) -> None: engine = create_engine(h.runtime.ledger.engine.url) try: - assert snapshot(engine) == expected + observed = snapshot(engine) + compared = owner_adjusted(expected, observed, h.project, method, keys) if keys else observed + assert compared == expected finally: engine.dispose() @@ -242,7 +288,17 @@ def failed_bookkeeping(h: ScopeHarness, method: str, key: str, error: dict[str, assert json.loads(row["error_json"]) == error -def assert_scope_delta(before: Snapshot, after: Snapshot, probe: Probe, *, assigned: bool) -> None: +def assert_scope_delta( + before: Snapshot, + after: Snapshot, + probe: Probe, + *, + assigned: bool, + project: str, + method: str, + keys: tuple[str, ...], +) -> None: + after = Snapshot(owner_adjusted(before, after, project, method, keys)) assert len(probe.writes) == len(probe.receipts) == 1 record, receipt = probe.writes[0], probe.receipts[0] for name in before: @@ -338,17 +394,32 @@ async def test_scope_transition_post_append_rollback_and_retries( assert error["code"] == -32603 and probe.hits == 1 and len(probe.writes) == 1 after = snapshot(h.runtime.ledger.engine) save(operation + "-fault", before, after, probe, error_code=error["code"]) - assert after == before, "HOLD_PARTIAL_PUBLICATION: post-append transition" - reopened(h, before) + assert owner_adjusted(before, after, h.project, method, ("fault",)) == before, ( + "HOLD_PARTIAL_PUBLICATION: post-append transition" + ) + reopened(h, before, method=method, keys=("fault",)) replay = await h.call(actor, method, "fault", body) assert replay.json()["error"] == error and probe.hits == 1 and len(probe.writes) == 1 - assert snapshot(h.runtime.ledger.engine) == before + assert ( + owner_adjusted( + before, snapshot(h.runtime.ledger.engine), h.project, method, ("fault",) + ) + == before + ) failed_bookkeeping(h, method, "fault", error) with monkeypatch.context() as patch: retry = install(patch, h, scopes, resource, event, mode="observe") response = value(await h.call(actor, method, "new-key", body))["scope"] after = snapshot(h.runtime.ledger.engine) - assert_scope_delta(before, after, retry, assigned=operation == "assign") + assert_scope_delta( + before, + after, + retry, + assigned=operation == "assign", + project=h.project, + method=method, + keys=("fault", "new-key"), + ) assert response == retry.writes[0].model_dump(mode="json") if operation != "assign": assert all(g["state"] == "REVOKED" for g in response["grants"]) @@ -407,8 +478,10 @@ async def test_http_assign_expiry_after_append_rolls_back_denied_publication( original_actor_check=True, session_active_before_clock_advance=True, ) - assert after == before, "HOLD_PARTIAL_PUBLICATION: actual post-append expiry denial" - reopened(h, before) + assert ( + owner_adjusted(before, after, h.project, method, ("expired-after-write",)) == before + ), "HOLD_PARTIAL_PUBLICATION: actual post-append expiry denial" + reopened(h, before, method=method, keys=("expired-after-write",)) assert file_hashes(tmp_path) == files and model.calls == 0 failed_bookkeeping(h, method, "expired-after-write", rejected.json()["error"]) # No replay or recovery after reversing a synthetic clock is claimed. diff --git a/tests/integration/test_revision_full_rpc.py b/tests/integration/test_revision_full_rpc.py index d36bf9c..b89a184 100644 --- a/tests/integration/test_revision_full_rpc.py +++ b/tests/integration/test_revision_full_rpc.py @@ -140,6 +140,13 @@ def assert_restore_proposal_delta( "resource_scope_history": 2, "resource_scope_receipts": 2, } + owner_rows = [ + json.loads(row) + for row in after.get("operation_owners", ()) + if json.loads(row)["operation_id"] == operation_id + ] + if owner_rows: + expected_counts["operation_owners"] = 1 for table, count in expected_counts.items(): rows = [json.loads(row) for row in set(after[table]) - set(before[table])] assert len(rows) == count diff --git a/tests/integration/test_stale_running_current_behavior.py b/tests/integration/test_stale_running_current_behavior.py index 0bb53d3..3743eb0 100644 --- a/tests/integration/test_stale_running_current_behavior.py +++ b/tests/integration/test_stale_running_current_behavior.py @@ -4,16 +4,12 @@ must change exactly what is pinned here and nothing else. Children are real processes killed for real, with a synthetic home; no model, login or network is used. -Observed today: - - a request that was interrupted after its domain change was committed stays RUNNING, and the same - idempotency key returns RUNNING for as long as it is asked (the handler never runs again); - - a request that was interrupted inside its handler stays RUNNING with nothing changed, and only a - new key makes it run; - - the operation row records no process that owned the request, so nothing can tell "owner gone" - from "owner slow" (rows that exist without such information are exactly these rows); - - the domain change and the terminal result are two separate commits for this command; - - the repeat of an applied change under a new key is refused by the revision guard, so nothing is - applied twice. +Updated D-B2 observations: + - measured commands with a confirmed dead owner seal on same-key replay; + - an unmeasured source connection stays RUNNING without automatic redispatch; + - operations columns stay unchanged; process identity lives in operation_owners; + - domain and normal terminal publication remain separate commits; + - a new key after an applied change is still refused by its revision guard. """ from __future__ import annotations @@ -73,6 +69,20 @@ async def main(workspace, mode, marker): assert created.error is None, created.error update = build("project/metadata/update", "rename", { "project_id": "project:stale-running", "expected_revision": 0, "name": "Renamed"}) + if mode == "excluded": + inbox = workspace / "inbox" + inbox.mkdir(exist_ok=True) + (inbox / "source.md").write_text("Synthetic source", encoding="utf-8") + async def stuck_connect(value): + Path(marker).write_text(str(os.getpid()), encoding="utf-8") + await asyncio.Event().wait() + return {} + runtime.bus._registry.decorate("project/source/connect", lambda handler: stuck_connect) + update = build("project/source/connect", "connect", { + "project_id": "project:stale-running", "relative_path": "source.md", + "media_type": "text/markdown", "authority": "OFFICIAL", + "cutoff_state": "ELIGIBLE", "security_class": "INTERNAL", + "expected_project_revision": 0}) if mode == "after-domain-commit": def die(*_args, **_kwargs): Path(marker).write_text(str(os.getpid()), encoding="utf-8") @@ -145,7 +155,7 @@ def _rename(key: str, expected_revision: int) -> JsonRpcRequest: ) -async def test_a_request_interrupted_after_its_change_was_committed_stays_running( +async def test_a_request_interrupted_after_its_change_was_committed_is_sealed( tmp_path: Path, ) -> None: workspace = tmp_path / "workspace" @@ -159,15 +169,16 @@ async def test_a_request_interrupted_after_its_change_was_committed_stays_runnin ) assert read["name"] == "Renamed" and read["revision"] == 1 - # The same key is answered with RUNNING every time; the handler never runs again. + # Same-key replay seals the confirmed dead owner once; it never reruns the handler. first = await runtime.bus.dispatch(_rename("rename", 0)) again = await runtime.bus.dispatch(_rename("rename", 0)) for replay in (first, again): - assert replay.error is None and replay.result is not None - assert replay.result["state"] == "RUNNING" - assert replay.result["value"] is None - assert replay.result["execution_observation"] == "UNKNOWN" - assert [row[1] for row in _rows(runtime, "project/metadata/update")] == ["RUNNING"] + assert replay.error is not None and replay.result is None + assert replay.error.data["reason_code"] == "OPERATION_OWNER_GONE" + assert replay.error.data["remote_observation"] == "UNKNOWN" + assert replay.error.data["new_request_required"] is True + assert first.error == again.error + assert [row[1] for row in _rows(runtime, "project/metadata/update")] == ["FAILED"] after_replays = domain_snapshot(runtime.ledger.engine) # The repeat of the applied change under a NEW key is refused by the revision guard: @@ -177,14 +188,14 @@ async def test_a_request_interrupted_after_its_change_was_committed_stays_runnin assert "expected_revision" in repeat.error.message assert domain_snapshot(runtime.ledger.engine) == after_replays assert [row[1] for row in _rows(runtime, "project/metadata/update")] == [ - "RUNNING", + "FAILED", "FAILED", ] finally: runtime.close() -async def test_a_request_interrupted_inside_its_handler_stays_running_with_nothing_changed( +async def test_a_request_interrupted_inside_its_handler_is_sealed_with_nothing_changed( tmp_path: Path, ) -> None: workspace = tmp_path / "workspace" @@ -197,11 +208,11 @@ async def test_a_request_interrupted_inside_its_handler_stays_running_with_nothi ) assert before["name"] == "Original" and before["revision"] == 0 - # Asking again with the same key neither runs the change nor closes the request. + # Asking again seals the request without running the interrupted handler. for _ in range(2): replay = await runtime.bus.dispatch(_rename("rename", 0)) - assert replay.error is None and replay.result is not None - assert replay.result["state"] == "RUNNING" and replay.result["value"] is None + assert replay.error is not None and replay.result is None + assert replay.error.data["reason_code"] == "OPERATION_OWNER_GONE" unchanged = value( await runtime.bus.query(request("project/read", "unchanged", {"project_id": PROJECT})) ) @@ -211,14 +222,14 @@ async def test_a_request_interrupted_inside_its_handler_stays_running_with_nothi fresh = value(await runtime.bus.dispatch(_rename("rename-new", 0))) assert fresh["name"] == "Renamed" and fresh["revision"] == 1 assert [row[1] for row in _rows(runtime, "project/metadata/update")] == [ - "RUNNING", + "FAILED", "SUCCEEDED", ] finally: runtime.close() -async def test_the_operation_row_records_no_owning_process(tmp_path: Path) -> None: +async def test_process_owner_is_separate_from_unchanged_operation_columns(tmp_path: Path) -> None: workspace = tmp_path / "workspace" assert _interrupted_child(tmp_path, workspace, "after-domain-commit") == 37 runtime = _open(workspace) @@ -235,8 +246,13 @@ async def test_the_operation_row_records_no_owning_process(tmp_path: Path) -> No .mappings() .one() ) - # Only the authenticated actor is recorded, never the process that was running the work, so - # a RUNNING row cannot say whether its owner is gone. D-B adds those columns on purpose. + # Runtime now supplies the separate owner table, never new operations columns. + from thoth.adapters.storage.operations import SqliteOperationStore + + stored = SqliteOperationStore(runtime.ledger.engine).read(str(stuck["operation_id"])) + assert stored is not None and stored.owner is not None + assert stored.owner.host_token is not None + assert "owner" not in stored.model_dump(mode="json") assert not _OWNER_PROCESS_COLUMNS & columns assert not _OWNER_PROCESS_COLUMNS & set(OperationRecord.model_fields) assert stuck["state"] == "RUNNING" @@ -340,3 +356,34 @@ def die(*_args: object, **_kwargs: object) -> None: assert domain_snapshot(runtime.ledger.engine) == applied finally: runtime.close() + + +async def test_excluded_source_connection_stays_running_after_actual_child_kill( + tmp_path: Path, +) -> None: + workspace = tmp_path / "workspace" + assert _interrupted_child(tmp_path, workspace, "excluded") is None + runtime = _open(workspace) + try: + before = domain_snapshot(runtime.ledger.engine) + command = request( + "project/source/connect", + "connect", + { + "project_id": PROJECT, + "relative_path": "source.md", + "media_type": "text/markdown", + "authority": "OFFICIAL", + "cutoff_state": "ELIGIBLE", + "security_class": "INTERNAL", + "expected_project_revision": 0, + }, + ) + for _ in range(2): + response = await runtime.bus.dispatch(command) + assert response.error is None and response.result is not None + assert response.result["state"] == "RUNNING" and response.result["value"] is None + assert [r[1] for r in _rows(runtime, "project/source/connect")] == ["RUNNING"] + assert domain_snapshot(runtime.ledger.engine) == before + finally: + runtime.close() diff --git a/tests/integration/test_workspace_owner_pin.py b/tests/integration/test_workspace_owner_pin.py new file mode 100644 index 0000000..0260e95 --- /dev/null +++ b/tests/integration/test_workspace_owner_pin.py @@ -0,0 +1,46 @@ +"""Pinning cannot manufacture ownership and protects a live reference from release.""" + +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +from thoth.adapters.storage.workspace_owner import ( + acquire_workspace_owner, + holds_workspace, + pin_workspace_owner, +) + + +def test_missing_workspace_is_not_created_or_owned(tmp_path: Path) -> None: + workspace = tmp_path / "absent" + assert not holds_workspace(workspace) + with pin_workspace_owner(workspace) as held: + assert not held and not workspace.exists() + assert not workspace.exists() + + +def test_pin_keeps_existing_lock_across_original_release(tmp_path: Path) -> None: + ownership = acquire_workspace_owner(tmp_path) + with pin_workspace_owner(tmp_path) as held: + assert held and holds_workspace(tmp_path) + with ThreadPoolExecutor(max_workers=1) as pool: + pool.submit(ownership.release).result(timeout=5) + assert holds_workspace(tmp_path) + with pin_workspace_owner(tmp_path) as nested: + assert nested + assert holds_workspace(tmp_path) + assert not holds_workspace(tmp_path) + + +def test_failed_body_releases_only_the_pin(tmp_path: Path) -> None: + ownership = acquire_workspace_owner(tmp_path) + try: + try: + with pin_workspace_owner(tmp_path) as held: + assert held + raise RuntimeError("injected") + except RuntimeError: + pass + assert holds_workspace(tmp_path) + finally: + ownership.release() + assert not holds_workspace(tmp_path) diff --git a/tests/unit/test_operation_host_identity.py b/tests/unit/test_operation_host_identity.py new file mode 100644 index 0000000..bd4ea36 --- /dev/null +++ b/tests/unit/test_operation_host_identity.py @@ -0,0 +1,204 @@ +"""Synthetic OS identities: raw values never leave the supplier as tokens.""" + +import hashlib +import hmac +import sys +import threading +from concurrent.futures import ThreadPoolExecutor +from contextlib import nullcontext +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from thoth.adapters import operation_host_identity as identity +from thoth.domain.deployment_mode import DeploymentMode + +MACHINE = "12345678-1234-1234-1234-123456789abc" +BOOT = "87654321-4321-4321-4321-cba987654321" + + +@pytest.fixture(autouse=True) +def clean_cache(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(identity, "_cache", None) + + +def registry( + monkeypatch: pytest.MonkeyPatch, + boots: tuple[object, object] = (12, 12), + *, + machine: object = MACHINE, + kind: int = 1, +): + values = iter(boots) + calls: list[str] = [] + + def opened(root: int, path: str, reserved: int, access: int): + assert root == 100 and reserved == 0 and access == (2 | 4) + assert path in { + r"SOFTWARE\Microsoft\Cryptography", + ( + r"SYSTEM\CurrentControlSet\Control\Session Manager\Memory Management" + r"\PrefetchParameters" + ), + } + return nullcontext(path) + + def query(key: str, name: str): + del key + calls.append(name) + return (next(values), 3) if name == "BootId" else (machine, kind) + + monkeypatch.setitem( + sys.modules, + "winreg", + SimpleNamespace( + HKEY_LOCAL_MACHINE=100, + KEY_READ=2, + KEY_WOW64_64KEY=4, + REG_DWORD=3, + REG_SZ=1, + OpenKey=opened, + QueryValueEx=query, + ), + ) + monkeypatch.setattr(identity.sys, "platform", "win32") + return calls + + +def test_windows_double_read_and_hash_only( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + calls = registry(monkeypatch) + token = identity.operation_host_token(DeploymentMode.LOCAL) + expected = hmac.new( + b"thoth.operation-owner.host.v1", + ("win32\0" + MACHINE.replace("-", "") + "\0prefetch-bootid:12").encode(), + hashlib.sha256, + ).hexdigest() + assert token is not None and token == expected and len(token) == 64 + assert identity.operation_host_token(DeploymentMode.LOCAL) == token + assert calls == ["BootId", "MachineGuid", "BootId"] + assert vars(identity)["_cache"] == (identity.os.getpid(), token) + assert MACHINE not in repr(vars(identity)["_cache"]) + assert capsys.readouterr() == ("", "") + + +@pytest.mark.parametrize( + "boots,machine,kind", + [ + ((12, 13), MACHINE, 1), + ((-1, -1), MACHINE, 1), + ((2**32, 2**32), MACHINE, 1), + ((True, True), MACHINE, 1), + ((12, 12), "bad-id", 1), + ((12, 12), "00000000-0000-0000-0000-000000000000", 1), + ((12, 12), MACHINE, 3), + ], +) +def test_invalid_registry_or_changed_boot_caches_none( + monkeypatch: pytest.MonkeyPatch, boots: tuple[object, object], machine: object, kind: int +) -> None: + calls = registry(monkeypatch, boots, machine=machine, kind=kind) + assert identity.operation_host_token(DeploymentMode.LOCAL) is None + count = len(calls) + assert identity.operation_host_token(DeploymentMode.LOCAL) is None + assert len(calls) == count + + +@pytest.mark.parametrize("error", [FileNotFoundError, PermissionError, OSError]) +def test_registry_failure_returns_none_without_exposing_error( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], error: type[OSError] +) -> None: + registry(monkeypatch) + + def fail(*args: object): + raise error(MACHINE) + + monkeypatch.setattr(sys.modules["winreg"], "OpenKey", fail) + assert identity.operation_host_token(DeploymentMode.LOCAL) is None + assert capsys.readouterr() == ("", "") + + +def test_zero_counter_is_valid(monkeypatch: pytest.MonkeyPatch) -> None: + registry(monkeypatch, (0, 0)) + assert identity.operation_host_token(DeploymentMode.LOCAL) is not None + + +def test_hosted_never_reads_even_when_cache_exists(monkeypatch: pytest.MonkeyPatch) -> None: + calls = registry(monkeypatch) + assert identity.operation_host_token(DeploymentMode.HOSTED_REVIEW) is None + assert calls == [] and vars(identity)["_cache"] is None + assert identity.operation_host_token(DeploymentMode.LOCAL) is not None + assert identity.operation_host_token(DeploymentMode.HOSTED_REVIEW) is None + assert len(calls) == 3 + + +def test_linux_reads_bounded_files_and_uses_os_specific_hash( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + machine, boot = tmp_path / "machine", tmp_path / "boot" + machine.write_text(MACHINE.replace("-", "") + "\n") + boot.write_text(BOOT + "\n") + real_open = Path.open + + def opened(path: Path, *args: object, **kwargs: object): + target = {"/etc/machine-id": machine, "/proc/sys/kernel/random/boot_id": boot}[ + path.as_posix() + ] + return real_open(target, "rb") + + monkeypatch.setattr(identity.sys, "platform", "linux") + monkeypatch.setattr(Path, "open", opened) + token = identity.operation_host_token(DeploymentMode.LOCAL) + expected = hmac.new( + b"thoth.operation-owner.host.v1", + ("linux\0" + MACHINE.replace("-", "") + "\0" + BOOT.replace("-", "")).encode(), + hashlib.sha256, + ).hexdigest() + assert token == expected + + +@pytest.mark.parametrize("raw", [b"", b"0" * 32, b"x" * 129, b"\xff" * 32]) +def test_linux_invalid_or_oversize_identity_fails_closed( + monkeypatch: pytest.MonkeyPatch, raw: bytes +) -> None: + import io + + monkeypatch.setattr(identity.sys, "platform", "linux") + + def opened(*args: object, **kwargs: object) -> io.BytesIO: + return io.BytesIO(raw) + + monkeypatch.setattr(Path, "open", opened) + assert identity.operation_host_token(DeploymentMode.LOCAL) is None + + +def test_concurrent_first_calls_read_once_and_new_pid_gets_new_cache( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[int] = [] + + def read() -> str: + calls.append(1) + return "a" * 64 + + monkeypatch.setattr(identity, "_read_token", read) + with ThreadPoolExecutor(max_workers=4) as pool: + assert ( + list(pool.map(identity.operation_host_token, [DeploymentMode.LOCAL] * 8)) + == ["a" * 64] * 8 + ) + assert calls == [1] + monkeypatch.setattr(identity.os, "getpid", lambda: 123456789) + assert identity.operation_host_token(DeploymentMode.LOCAL) == "a" * 64 + assert calls == [1, 1] + old_lock = vars(identity)["_lock"] + vars(identity)["_after_fork"]() + assert vars(identity)["_cache"] is None and vars(identity)["_lock"] is not old_lock + assert isinstance(vars(identity)["_lock"], type(threading.Lock())) + + +def test_unsupported_os_does_not_read(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(identity.sys, "platform", "unsupported") + assert identity.operation_host_token(DeploymentMode.LOCAL) is None diff --git a/tests/unit/test_operation_owner_allowances.py b/tests/unit/test_operation_owner_allowances.py new file mode 100644 index 0000000..c815758 --- /dev/null +++ b/tests/unit/test_operation_owner_allowances.py @@ -0,0 +1,139 @@ +"""Bookkeeping accepts one exact new owner, never unrelated or existing-row changes.""" + +import json +from collections.abc import Mapping +from copy import deepcopy + +import pytest +from tests.atomicity.harness import ( + AllowedRow, + Snapshot, + assert_phase_delta, + unchanged_or_operation_owner, +) +from tests.integration.storage_coverage_helpers import request + + +def encoded(value: Mapping[str, object]) -> str: + return json.dumps(value, sort_keys=True) + + +def states() -> tuple[Snapshot, Snapshot, tuple[AllowedRow, ...]]: + operation: dict[str, object] = { + "operation_id": "operation:new", + "project_id": "project:test", + "method": "project/metadata/update", + "idempotency_key": "new", + "state": "FAILED", + } + owner: dict[str, object] = { + "operation_id": "operation:new", + "worker_id": "worker:synthetic", + "process_id": 123, + "process_marker": "marker", + "host_token": "b" * 64, + } + before: Snapshot = { + "operations": (), + "operation_owners": (), + "domain": (encoded({"value": 1}),), + } + after: Snapshot = { + **before, + "operations": (encoded(operation),), + "operation_owners": (encoded(owner),), + } + return before, after, (AllowedRow("operations", operation, "added", {}),) + + +def test_exact_new_owner_and_ownerless_environment_are_allowed() -> None: + before, after, allowed = states() + assert_phase_delta(before, after, allowed) + after["operation_owners"] = () + assert_phase_delta(before, after, allowed) + + +@pytest.mark.parametrize( + "case", ["foreign", "two", "duplicate", "modify", "delete", "backfill", "domain"] +) +def test_owner_rules_never_weaken_other_row_or_domain_checks(case: str) -> None: + before, after, allowed = states() + own = json.loads(after["operation_owners"][0]) + old = {**own, "operation_id": "operation:old"} + if case == "foreign": + after["operation_owners"] = (encoded(old),) + if case == "two": + after["operation_owners"] += (encoded(old),) + if case == "duplicate": + after["operation_owners"] *= 2 + if case in {"modify", "delete"}: + before["operation_owners"] = (encoded(old),) + if case == "modify": + after["operation_owners"] += (encoded({**old, "process_id": 456}),) + if case == "backfill": + before["operations"] = after["operations"] + if case == "domain": + after["domain"] = (encoded({"value": 2}),) + with pytest.raises(AssertionError): + assert_phase_delta(before, after, allowed) + + +def test_domain_projection_still_compares_every_domain_byte() -> None: + before, after, _ = states() + command = request("project/metadata/update", "new", {"project_id": "project:test"}) + assert unchanged_or_operation_owner(before, after, "operation_owners", command) + assert unchanged_or_operation_owner(before, after, "domain", command) + changed = deepcopy(after) + changed["domain"] = (encoded({"value": 2}),) + assert not unchanged_or_operation_owner(before, changed, "domain", command) + changed["operation_owners"] += ( + encoded( + {**json.loads(changed["operation_owners"][0]), "operation_id": "operation:foreign"} + ), + ) + with pytest.raises(AssertionError): + unchanged_or_operation_owner(before, changed, "operation_owners", command) + + +@pytest.mark.parametrize( + "case", ["valid", "foreign", "two", "modify", "delete", "backfill", "domain"] +) +def test_scope_view_keeps_raw_evidence_and_rejects_other_changes(case: str) -> None: + from tests.integration.test_resource_scope_transition_boundaries import ( + Snapshot as ScopeSnapshot, + ) + from tests.integration.test_resource_scope_transition_boundaries import ( + owner_adjusted, + ) + + before, after, _ = states() + owner = json.loads(after["operation_owners"][0]) + old = {**owner, "operation_id": "operation:old"} + if case == "foreign": + after["operation_owners"] = (encoded(old),) + if case == "two": + after["operation_owners"] += (encoded(old),) + if case in {"modify", "delete"}: + before["operation_owners"] = (encoded(old),) + if case == "modify": + after["operation_owners"] += (encoded({**old, "process_id": 456}),) + if case == "backfill": + before["operations"] = after["operations"] + if case == "domain": + after["domain"] = (encoded({"value": 2}),) + left = ScopeSnapshot({k: v for k, v in before.items() if k != "operations"}) + left.operation_rows = before["operations"] + right = ScopeSnapshot({k: v for k, v in after.items() if k != "operations"}) + right.operation_rows = after["operations"] + saved = deepcopy(right) + if case == "valid": + assert ( + owner_adjusted(left, right, "project:test", "project/metadata/update", ("new",)) == left + ) + else: + with pytest.raises(AssertionError): + assert ( + owner_adjusted(left, right, "project:test", "project/metadata/update", ("new",)) + == left + ) + assert right == saved diff --git a/tests/unit/test_operation_owner_policy.py b/tests/unit/test_operation_owner_policy.py new file mode 100644 index 0000000..624b547 --- /dev/null +++ b/tests/unit/test_operation_owner_policy.py @@ -0,0 +1,180 @@ +"""Every owner predicate fails closed; the approved list and wire text are exact.""" + +import json +from contextlib import nullcontext +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import cast + +import pytest + +from thoth.domain.deployment_mode import STALE_RUNNING_MESSAGE, STALE_RUNNING_REASON +from thoth.domain.enums import OperationState +from thoth.domain.operation import OperationOwnerRecord, OperationRecord +from thoth.ports.journal import JournalPort +from thoth.ports.operation import OperationStorePort +from thoth.ports.runtime import AtomicUnitOfWorkPort, ClockPort +from thoth.protocol.jsonrpc import JsonRpcError, JsonRpcRequest, RpcErrorCode +from thoth.protocol.operation_owner import ( + SEALABLE_METHODS, + OperationOwner, + owner_gone_error, + stale_running_error, +) +from thoth.protocol.registry import PUBLIC_METHODS + + +@dataclass +class Worker: + alive: bool | None = False + worker_id: str = "worker:current" + process_id: int = 20 + process_marker: str = "marker:current" + calls: int = 0 + + def is_alive(self, process_id: int, process_marker: str, worker_id: str) -> bool | None: + self.calls += 1 + assert (process_id, process_marker, worker_id) == (10, "marker:old", "worker:old") + return self.alive + + def close(self) -> None: + pass + + +def operation() -> OperationRecord: + return OperationRecord( + operation_id="operation:policy", + project_id="project:policy", + method="project/metadata/update", + idempotency_key="key", + scope_digest="a" * 64, + created_at=datetime(2026, 10, 19, tzinfo=UTC), + state=OperationState.RUNNING, + owner=OperationOwnerRecord( + worker_id="worker:old", process_id=10, process_marker="marker:old", host_token="b" * 64 + ), + ) + + +def policy( + worker: Worker, token: str | None = "b" * 64, held: bool = True, pin: bool = True +) -> OperationOwner: + return OperationOwner( + worker=worker, + host_token=token, + holds_workspace=lambda: held, + pin_workspace=lambda: nullcontext(pin), + operations=cast(OperationStorePort, object()), + uow=cast(AtomicUnitOfWorkPort, object()), + journal=cast(JournalPort, object()), + clock=cast(ClockPort, object()), + ) + + +@pytest.mark.parametrize( + "case", + [ + "eligible", + "alive", + "unknown", + "no-owner", + "no-token", + "different-token", + "old-null-token", + "old-unknown-marker", + "current-unknown-marker", + "no-lock", + "pending", + "terminal", + "excluded", + "method-mismatch", + ], +) +def test_all_conditions_are_required(case: str) -> None: + worker, original = Worker(), operation() + token: str | None = "b" * 64 + method = original.method + if case == "alive": + worker.alive = True + if case == "unknown": + worker.alive = None + if case == "no-owner": + original = original.model_copy(update={"owner": None}) + if case == "no-token": + token = None + if case == "different-token": + token = "c" * 64 + if case in {"old-null-token", "old-unknown-marker"}: + assert original.owner is not None + update = {"host_token": None} if case == "old-null-token" else {"process_marker": "UNKNOWN"} + original = original.model_copy(update={"owner": original.owner.model_copy(update=update)}) + if case == "current-unknown-marker": + worker.process_marker = "UNKNOWN" + if case == "pending": + original = original.model_copy(update={"state": OperationState.PENDING}) + if case == "terminal": + original = original.model_copy(update={"state": OperationState.SUCCEEDED}) + if case == "excluded": + method = "thread/resume" + original = original.model_copy(update={"method": method}) + if case == "method-mismatch": + method = "project/activate" + subject = policy(worker, token, held=case != "no-lock") + assert subject.gone(original, method) is (case == "eligible") + if case not in {"eligible", "alive", "unknown"}: + assert worker.calls == 0 + if case in {"no-token", "current-unknown-marker"}: + assert subject.record() is None + + +def test_lock_lost_before_pin_and_different_key_cannot_write() -> None: + def unmeasured(*args: object) -> None: + pass + + original = operation() + request = JsonRpcRequest.model_validate( + { + "id": "key", + "method": original.method, + "params": {"_meta": {"idempotencyKey": "key"}, "input": {}}, + } + ) + assert policy(Worker(), pin=False).seal(request, original, unmeasured) is None + other = request.model_copy( + update={ + "params": request.params.model_copy( + update={ + "meta": request.params.meta.model_copy(update={"idempotency_key": "different"}), + } + ) + } + ) + assert policy(Worker()).seal(other, original, unmeasured) is None + + +def test_list_and_bilingual_error_are_exact() -> None: + fixture = json.loads((Path(__file__).parents[1] / "fixtures/sealable_methods.json").read_text()) + assert frozenset(fixture["methods"]) == SEALABLE_METHODS and len(SEALABLE_METHODS) == 67 + assert set(PUBLIC_METHODS) >= SEALABLE_METHODS + expected = ( + "이전에 보낸 같은 요청을 실행하던 THOTH가 끝나서 요청이 마무리되지 못했습니다. " + "변경이 저장됐는지 확인한 뒤 필요하면 새 요청으로 다시 보내세요. / " + "The THOTH process that was running this request ended before it finished. " + "Check whether the change was saved, then send it again as a new request " + "if you still need it." + ) + assert owner_gone_error().message == expected + assert owner_gone_error().data == { + "reason_code": "OPERATION_OWNER_GONE", + "remote_observation": "UNKNOWN", + "new_request_required": True, + } + assert ( + stale_running_error().model_dump_json() + == JsonRpcError( + code=RpcErrorCode.DOMAIN_REJECTED, + message=STALE_RUNNING_MESSAGE, + data={"reason_code": STALE_RUNNING_REASON, "remote_observation": "UNKNOWN"}, + ).model_dump_json() + )