From 01cc741278d1badf756720e2bbdd57d4bc57d196 Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 00:51:31 +0000 Subject: [PATCH 01/93] Fork card: short list pages and fork_too_large (422) for a first entry past the response budget Co-Authored-By: Claude Sonnet 5.5 --- .../cultnet/governance/fork-card-vectors.json | 944 ++++++++++++++++++ contracts/cultnet/governance/fork-card.md | 6 +- .../cultnet/governance/fork-card.schema.json | 1 + .../test/governance-vectors.test.ts | 40 + scripts/gen-governance-vectors.py | 23 +- .../GovernanceVectorTests.cs | 49 + 6 files changed, 1058 insertions(+), 5 deletions(-) diff --git a/contracts/cultnet/governance/fork-card-vectors.json b/contracts/cultnet/governance/fork-card-vectors.json index 7c6f5df7..124cbbd3 100644 --- a/contracts/cultnet/governance/fork-card-vectors.json +++ b/contracts/cultnet/governance/fork-card-vectors.json @@ -2692,6 +2692,16 @@ "error": "request_invalid: the request does not satisfy its document" }, "messagePackHex": "83AD736368656D6156657273696F6EB867616D6563756C742E666F726B2E7265667573616C2E7630A4636F6465AF726571756573745F696E76616C6964A56572726F72D93A726571756573745F696E76616C69643A20746865207265717565737420646F6573206E6F7420736174697366792069747320646F63756D656E74" + }, + { + "name": "fork_too_large", + "httpStatus": 422, + "message": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "fork_too_large", + "error": "fork_too_large: fork gamecult-chat:question:q3 (order 3) alone is past the response budget; read it by id" + }, + "messagePackHex": "83AD736368656D6156657273696F6EB867616D6563756C742E666F726B2E7265667573616C2E7630A4636F6465AE666F726B5F746F6F5F6C61726765A56572726F72D969666F726B5F746F6F5F6C617267653A20666F726B2067616D6563756C742D636861743A7175657374696F6E3A713320286F7264657220332920616C6F6E6520697320706173742074686520726573706F6E7365206275646765743B2072656164206974206279206964" } ], "unavailable": [ @@ -6561,6 +6571,940 @@ ] } ], + "listShortPages": [ + { + "name": "a_short_page_resumes_below_its_last_entry", + "ordinal": 9, + "entries": [ + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q3", + "title": "Fork 3", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q3", + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", + "counted_answer_id": null, + "answers": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q2", + "title": "Fork 2", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 2, + "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q2", + "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79", + "counted_answer_id": null, + "answers": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q1", + "title": "Fork 1", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 1, + "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q1", + "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a", + "counted_answer_id": null, + "answers": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q0", + "title": "Fork 0", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 0, + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q0", + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d", + "counted_answer_id": null, + "answers": [] + } + } + ], + "steps": [ + { + "request": { + "schemaVersion": "gamecult.fork.list_request.v0", + "mind": "gamecult-chat", + "status": null, + "cursor": null, + "limit": 3 + }, + "response": { + "schemaVersion": "gamecult.fork.list.v0", + "mind": "gamecult-chat", + "stamp": "s9", + "open_count": 4, + "recorded_count": 0, + "forks": [ + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q3", + "title": "Fork 3", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q3", + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", + "counted_answer_id": null, + "answers": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q2", + "title": "Fork 2", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 2, + "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q2", + "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79", + "counted_answer_id": null, + "answers": [] + } + } + ], + "next_cursor": "c2" + }, + "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A27339AA6F70656E5F636F756E7404AE7265636F726465645F636F756E7400A5666F726B739283AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A57469746C65A6466F726B2033A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657203AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532B1636F756E7465645F616E737765725F6964C0A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132A57469746C65A6466F726B2032A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657202AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72A26332" + }, + { + "request": { + "schemaVersion": "gamecult.fork.list_request.v0", + "mind": "gamecult-chat", + "status": null, + "cursor": "c2", + "limit": 3 + }, + "response": { + "schemaVersion": "gamecult.fork.list.v0", + "mind": "gamecult-chat", + "stamp": "s9", + "open_count": 4, + "recorded_count": 0, + "forks": [ + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q1", + "title": "Fork 1", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 1, + "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q1", + "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a", + "counted_answer_id": null, + "answers": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q0", + "title": "Fork 0", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 0, + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q0", + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d", + "counted_answer_id": null, + "answers": [] + } + } + ], + "next_cursor": null + }, + "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A27339AA6F70656E5F636F756E7404AE7265636F726465645F636F756E7400A5666F726B739283AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131A57469746C65A6466F726B2031A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657201AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361B1636F756E7465645F616E737765725F6964C0A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7130A57469746C65A6466F726B2030A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657200AC6F7074696F6E735F68617368D94031373737626430366666616664663665383162656435393739663337313933633462346537633364323530346639303465393133313031326630373637613964A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7130AC6F7074696F6E735F68617368D94031373737626430366666616664663665383162656435393739663337313933633462346537633364323530346639303465393133313031326630373637613964B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72C0" + } + ] + }, + { + "name": "a_first_entry_past_the_budget_is_fork_too_large_and_the_client_continues_below_its_order", + "ordinal": 9, + "entries": [ + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q3", + "title": "Fork 3", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q3", + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", + "counted_answer_id": null, + "answers": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q2", + "title": "Fork 2", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 2, + "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q2", + "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79", + "counted_answer_id": null, + "answers": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q1", + "title": "Fork 1", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 1, + "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q1", + "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a", + "counted_answer_id": null, + "answers": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q0", + "title": "Fork 0", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 0, + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q0", + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d", + "counted_answer_id": null, + "answers": [] + } + } + ], + "steps": [ + { + "request": { + "schemaVersion": "gamecult.fork.list_request.v0", + "mind": "gamecult-chat", + "status": null, + "cursor": null, + "limit": 3 + }, + "refusal": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "fork_too_large", + "error": "fork_too_large: fork gamecult-chat:question:q3 (order 3) alone is past the response budget; read it by id" + }, + "refusalHex": "83AD736368656D6156657273696F6EB867616D6563756C742E666F726B2E7265667573616C2E7630A4636F6465AE666F726B5F746F6F5F6C61726765A56572726F72D969666F726B5F746F6F5F6C617267653A20666F726B2067616D6563756C742D636861743A7175657374696F6E3A713320286F7264657220332920616C6F6E6520697320706173742074686520726573706F6E7365206275646765743B2072656164206974206279206964", + "httpStatus": 422, + "fork_id": "gamecult-chat:question:q3", + "order": 3 + }, + { + "request": { + "schemaVersion": "gamecult.fork.list_request.v0", + "mind": "gamecult-chat", + "status": null, + "cursor": "c3", + "limit": 3 + }, + "response": { + "schemaVersion": "gamecult.fork.list.v0", + "mind": "gamecult-chat", + "stamp": "s9", + "open_count": 4, + "recorded_count": 0, + "forks": [ + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q2", + "title": "Fork 2", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 2, + "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q2", + "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79", + "counted_answer_id": null, + "answers": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q1", + "title": "Fork 1", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 1, + "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q1", + "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a", + "counted_answer_id": null, + "answers": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q0", + "title": "Fork 0", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 0, + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q0", + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d", + "counted_answer_id": null, + "answers": [] + } + } + ], + "next_cursor": null + }, + "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A27339AA6F70656E5F636F756E7404AE7265636F726465645F636F756E7400A5666F726B739383AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132A57469746C65A6466F726B2032A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657202AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739B1636F756E7465645F616E737765725F6964C0A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131A57469746C65A6466F726B2031A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657201AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361B1636F756E7465645F616E737765725F6964C0A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7130A57469746C65A6466F726B2030A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657200AC6F7074696F6E735F68617368D94031373737626430366666616664663665383162656435393739663337313933633462346537633364323530346639303465393133313031326630373637613964A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7130AC6F7074696F6E735F68617368D94031373737626430366666616664663665383162656435393739663337313933633462346537633364323530346639303465393133313031326630373637613964B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72C0" + } + ] + }, + { + "name": "fork_too_large_on_order_zero_leaves_nothing_below", + "ordinal": 9, + "entries": [ + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q1", + "title": "Fork 1", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 1, + "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q1", + "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a", + "counted_answer_id": null, + "answers": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q0", + "title": "Fork 0", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 0, + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q0", + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d", + "counted_answer_id": null, + "answers": [] + } + } + ], + "steps": [ + { + "request": { + "schemaVersion": "gamecult.fork.list_request.v0", + "mind": "gamecult-chat", + "status": null, + "cursor": null, + "limit": 5 + }, + "response": { + "schemaVersion": "gamecult.fork.list.v0", + "mind": "gamecult-chat", + "stamp": "s9", + "open_count": 2, + "recorded_count": 0, + "forks": [ + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q1", + "title": "Fork 1", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 1, + "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q1", + "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a", + "counted_answer_id": null, + "answers": [] + } + } + ], + "next_cursor": "c1" + }, + "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A27339AA6F70656E5F636F756E7402AE7265636F726465645F636F756E7400A5666F726B739183AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131A57469746C65A6466F726B2031A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657201AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72A26331" + }, + { + "request": { + "schemaVersion": "gamecult.fork.list_request.v0", + "mind": "gamecult-chat", + "status": null, + "cursor": "c1", + "limit": 5 + }, + "refusal": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "fork_too_large", + "error": "fork_too_large: fork gamecult-chat:question:q0 (order 0) alone is past the response budget; read it by id" + }, + "refusalHex": "83AD736368656D6156657273696F6EB867616D6563756C742E666F726B2E7265667573616C2E7630A4636F6465AE666F726B5F746F6F5F6C61726765A56572726F72D969666F726B5F746F6F5F6C617267653A20666F726B2067616D6563756C742D636861743A7175657374696F6E3A713020286F7264657220302920616C6F6E6520697320706173742074686520726573706F6E7365206275646765743B2072656164206974206279206964", + "httpStatus": 422, + "fork_id": "gamecult-chat:question:q0", + "order": 0 + } + ] + } + ], "mindRoutes": { "minds": { "gamecult-chat": "huginn-gamecult-chat.sock", diff --git a/contracts/cultnet/governance/fork-card.md b/contracts/cultnet/governance/fork-card.md index 51357a89..c5b16934 100644 --- a/contracts/cultnet/governance/fork-card.md +++ b/contracts/cultnet/governance/fork-card.md @@ -118,15 +118,15 @@ Source: the desk's client needs (Ratatoskr PR #24 section 12, `ratatoskr-desk-sp Calls 1 and 2 are enough for a read-only desk. Every response is the pinned MessagePack document or its JSON form with the same keys (open choice 1 of `vote-api.md`: the media type is unsettled). -**Auth, in order, per route.** (1) A bearer, else `unauthenticated` (401). (2) The token's audience is `bifrost-desk`: **the Matrix Authentication Service token chat holds today is refused, and so is any `votes.*` token** (audience `bifrost-votes`), both as `unauthenticated`. (3) The scope the route needs, else `insufficient_scope` (403): `desk.read` for calls 1 and 2, `desk.answer` for call 3, and neither implies the other. (4) The account behind `sub` is an Active member, else `forbidden` (403), as in the vote API; scope is judged first. (5) For call 3 the body is then shaped (steps 1 and 2 above), so **a non-Active member who sends a bad body, including one naming `answerer` or `authority`, gets `forbidden`, not a body refusal**; a body naming either key at any depth is `body_forbidden_field` only for an Active member. (6) Only then is Huginn asked, so `unavailable` never hides an auth or body refusal. A `votes.*` token touches none of the three. Heimdall's file leaves the audience and the claims check open; `insufficient_scope`, `forbidden`, `request_invalid` (400, a list or read request that fails its document) and `request_id_conflict` are added to the door refusals. +**Auth, in order, per route.** (1) A bearer, else `unauthenticated` (401). (2) The token's audience is `bifrost-desk`: **the Matrix Authentication Service token chat holds today is refused, and so is any `votes.*` token** (audience `bifrost-votes`), both as `unauthenticated`. (3) The scope the route needs, else `insufficient_scope` (403): `desk.read` for calls 1 and 2, `desk.answer` for call 3, and neither implies the other. (4) The account behind `sub` is an Active member, else `forbidden` (403), as in the vote API; scope is judged first. (5) For call 3 the body is then shaped (steps 1 and 2 above), so **a non-Active member who sends a bad body, including one naming `answerer` or `authority`, gets `forbidden`, not a body refusal**; a body naming either key at any depth is `body_forbidden_field` only for an Active member. (6) Only then is Huginn asked, so `unavailable` never hides an auth or body refusal. A `votes.*` token touches none of the three. Heimdall's file leaves the audience and the claims check open; `insufficient_scope`, `forbidden`, `request_invalid` (400, a list or read request that fails its document; an unknown key, `authority` included, is `request_invalid` here, whatever Huginn's own decode does with it) and `request_id_conflict` are added to the door refusals, and so is `fork_too_large` (422, list only; see List). -**List.** `list_request`: `{mind, status, cursor, limit}`. `mind` is the route's `{mind}`, a Mind id from the configured list. `status` is `open`, `recorded` or nil for both; `recorded` means a closed card. Cards come **newest first by `order`**. `order` is the card's unique rank per Mind: an integer from 0 that never changes once the card exists, later questions only appending (Huginn derives it from the Mind's questions sorted by admission ordinal, then key). A Mind never holds two cards of one `order`; a list or card set that does is refused, and the refusal names both fork ids. 0 is legal, but no cursor names it (`c0` fails the cursor pattern): a page ending at order 1 returns `c1`, and the next page returns the order-0 card alone with `next_cursor` nil. `limit` is 0 to 50; **`limit` 0 returns no entries, only the counts and the stamp**, which is what "N open" on the Mind's header needs. `cursor` is nil or `c`: the cards whose `order` is below N; a client treats it as opaque and sends back the `next_cursor` it was given. `list`: `{mind, stamp, open_count, recorded_count, forks[entry], next_cursor}`. The counts cover every card the viewer may see, whatever `status` and `cursor` asked for. `stamp` is `s`, N the receiver's last-seen Huginn admission ordinal for the Mind's desk documents: the same stamp means the same state, so a desk polls `limit` 0 and fetches bodies only when the stamp moves. `next_cursor` is nil on the last page. An `entry` is `{card, record}`: the card and its `record` (the answers with their outcomes and the derived `counted_answer_id`). +**List.** `list_request`: `{mind, status, cursor, limit}`. `mind` is the route's `{mind}`, a Mind id from the configured list. `status` is `open`, `recorded` or nil for both; `recorded` means a closed card. Cards come **newest first by `order`**. `order` is the card's unique rank per Mind: an integer from 0 that never changes once the card exists, later questions only appending (Huginn derives it from the Mind's questions sorted by admission ordinal, then key). A Mind never holds two cards of one `order`; a list or card set that does is refused, and the refusal names both fork ids. 0 is legal, but no cursor names it (`c0` fails the cursor pattern): a page ending at order 1 returns `c1`, and the next page returns the order-0 card alone with `next_cursor` nil. `limit` is 0 to 50; **`limit` 0 returns no entries, only the counts and the stamp**, which is what "N open" on the Mind's header needs. `cursor` is nil or `c`: the cards whose `order` is below N; a client treats it as opaque and sends back the `next_cursor` it was given. `list`: `{mind, stamp, open_count, recorded_count, forks[entry], next_cursor}`. The counts cover every card the viewer may see, whatever `status` and `cursor` asked for. `stamp` is `s`, N the receiver's last-seen Huginn admission ordinal for the Mind's desk documents: the same stamp means the same state, so a desk polls `limit` 0 and fetches bodies only when the stamp moves. `next_cursor` is nil on the last page. **A page may hold fewer than `limit` entries** when the next entry would push the encoded response past the receiver's response budget (Huginn: 65536 bytes); it then holds at least one, `next_cursor` is `c` so the next page resumes below it, and the counts and the stamp are those of the full list, unchanged. **`fork_too_large`** (HTTP 422, list only) refuses a page whose **first** entry alone is past the budget: 422 because the request is well formed and authorised but names a resource the list cannot carry, and 413 is for a request body that is too large, which this is not; retrying returns the same refusal. Its `error` names the fork id and its `order` (`fork_too_large: fork (order ) alone is past the response budget; read it by id`). The client reads that fork with the read route, which always returns the whole entry and never this refusal, and continues the list with cursor `c`, the cards strictly below that order; `c` is valid only when n is at least 1, so for order 0 there is nothing below and the client has reached the end. `limit` 0 returns no entries and never refuses. (Ruled by Persona, 2026-11-11.) An `entry` is `{card, record}`: the card and its `record` (the answers with their outcomes and the derived `counted_answer_id`). **Read.** `read_request` is `{mind, fork_id}`; the response is the `entry` for one fork of that Mind. Its `card.options_hash` is **computed by the server** from the five shown members as in "Options hash" and the client recomputes it from what it rendered; a card whose hash is not the hash of its members is no card (negative vector `card_hash_wrong`). The record's `fork_id` and `options_hash` are the card's, and `counted_answer_id` is derived, not copied (negative vectors for each). Every `desk.read` holder sees every fork's card and outcome in a configured Mind; only a decider's notes depend on the caller, going to callers in the admin-set `deciders` list, which Bifrost enforces at the door (ruling `desk-read-visibility`, option (c)); `fork_unknown` is only for a fork id that does not exist under the route's Mind (an unknown Mind answers the same, M2), so no fork is ever hidden by it, and a caller without `desk.read` is refused by the auth order above (`insufficient_scope`) before any lookup. **Bifrost applies visibility at the door; Huginn returns the whole record to the door.** Every `desk.read` holder sees cards and outcomes; notes (a decider's own words) only go to callers in the admin-set `deciders` list. The read and list shapes are unchanged and carry no audience field (`eureka-substrate:question:desk-read-visibility`, Heimdall's option (c)). A stripped read serves a decider's `note` as `note: null`, never omitted (map-mode: every key is written); `desk_answer.note` is already nullable in the schema, so a served record validates as it stands. **Answer.** The body is the `answer_request` and nothing else a client may set: `fork_id`, `options_hash`, `answer`, `answered_at`, `request_id` (plus `retracted` and `retracts`, written `false` and nil unless the call retracts). The response is the stored outcome: `Counted`, `Revision`, `Retraction`, or `Refused` with its code, and the **`counted_answer_id`** the fork has once this call is applied (`n` or nil; derived as in "The counted answer is derived", the same value the `record` projection carries, so the desk needs no second read). A replay returns the stored outcome with the `counted_answer_id` current at the retry, not the one it had when first stored. **A `stale_fork` refusal carries the current card** in `answer_result.card`, so the desk can show the new fork without another read. Nothing is stored for a step 1 or 2 refusal, an unknown fork (or an unknown Mind, or a fork another Mind holds) or a `request_id_conflict`. `unavailable` promises nothing either way. -**Vectors (`transport` in `fork-card-vectors.json`).** `listRequest`, `readRequest` and `entry` (valid and invalid, with bytes); `listInvalid` (including `two_cards_share_one_order`, whose `refusal` is the exact text both judges must produce: `order 4 is shared by and `); `listScenarios` (six scenarios: over one Mind's five cards, paged open-only, recorded-only, all and `limit` 0; an empty Mind; and `order_zero_is_legal_but_no_cursor_names_it`, four cards of orders 3 to 0 paged by 3, whose first page ends at order 1 with `c1` and whose second page is the order-0 card with `next_cursor` nil; each response with its bytes, the cursor of page two being page one's `next_cursor`); `authCases` (twenty-two cases: the MAS token, three `votes.*` tokens, a desk token with the other route's scope, a non-Active member on each route, and Huginn down on each route, with auth and membership judged first). Both suites replay the list requests through a reference judge and the auth cases through another. `mindRoutes` (a configured list of two Minds with their sockets and forks; 57 cases over the three routes: bad and percent-encoded segments, unknown Mind against unknown fork with `sameResponse` pairs of equal bytes, the POST on a Mind that does not hold the fork, scopes that are not per Mind, and auth judged before the Mind lookup) is replayed by both suites through a reference judge that reports the refusal, the socket it would open and the Mind that stored an answer. Top-level `unavailable` pins the 503 document. Scenarios pin: an `unavailable` where Huginn never had the request, retried into a `Counted` answer; an `unavailable` where Huginn had stored it, whose retry returns the stored outcome and adds no Revision; a replayed `Refused` with its card; `request_id_conflict`; the same id from another account; and a non-Active member with a bad body, with the door down, getting `forbidden` first. A bad body is refused before the door is asked. +**Vectors (`transport` in `fork-card-vectors.json`).** `listRequest`, `readRequest` and `entry` (valid and invalid, with bytes); `listInvalid` (including `two_cards_share_one_order`, whose `refusal` is the exact text both judges must produce: `order 4 is shared by and `); `listScenarios` (six scenarios: over one Mind's five cards, paged open-only, recorded-only, all and `limit` 0; an empty Mind; and `order_zero_is_legal_but_no_cursor_names_it`, four cards of orders 3 to 0 paged by 3, whose first page ends at order 1 with `c1` and whose second page is the order-0 card with `next_cursor` nil; each response with its bytes, the cursor of page two being page one's `next_cursor`); `listShortPages` (three scenarios: a page that stops short and resumes below its last entry; `fork_too_large` on a first entry of order 3, continued with `c3`; and a short page ending at order 1 followed by `fork_too_large` on order 0, where the client is at the end; each step carries its bytes, and the receiver's budget is in no vector, so the judges check a short page as a nonempty strict prefix of the unbudgeted page and a refusal as naming the entry the page would open with); `authCases` (twenty-two cases: the MAS token, three `votes.*` tokens, a desk token with the other route's scope, a non-Active member on each route, and Huginn down on each route, with auth and membership judged first). Both suites replay the list requests through a reference judge and the auth cases through another. `mindRoutes` (a configured list of two Minds with their sockets and forks; 57 cases over the three routes: bad and percent-encoded segments, unknown Mind against unknown fork with `sameResponse` pairs of equal bytes, the POST on a Mind that does not hold the fork, scopes that are not per Mind, and auth judged before the Mind lookup) is replayed by both suites through a reference judge that reports the refusal, the socket it would open and the Mind that stored an answer. Top-level `unavailable` pins the 503 document. Scenarios pin: an `unavailable` where Huginn never had the request, retried into a `Counted` answer; an `unavailable` where Huginn had stored it, whose retry returns the stored outcome and adds no Revision; a replayed `Refused` with its card; `request_id_conflict`; the same id from another account; and a non-Active member with a bad body, with the door down, getting `forbidden` first. A bad body is refused before the door is asked. **Bifrost must still build:** all three routes; the `bifrost-desk` audience and the `desk.read` and `desk.answer` scopes on the OIDC token it issues to chat; the configured Mind list and its sockets (M1); the projection of cards and records from Huginn, the stamp, the cursor and the counts; the `DeskAnswer` call on the second door and the pass-through of its outcome; the decider list, sent with every answer, read and list request from the admin-set list only (M5); the `unavailable` answer. **Huginn must still build** the second door's wire, the per-call decider list it receives with each answer, read and list request, keeping no copy (M5), and the read side the projection uses (Eureka's cut spec, after her record PR). **The desk may build today** against the vectors: nothing in them needs a live service. diff --git a/contracts/cultnet/governance/fork-card.schema.json b/contracts/cultnet/governance/fork-card.schema.json index 62acc30a..bd4c21d8 100644 --- a/contracts/cultnet/governance/fork-card.schema.json +++ b/contracts/cultnet/governance/fork-card.schema.json @@ -714,6 +714,7 @@ "body_unknown_field", "body_invalid", "fork_unknown", + "fork_too_large", "insufficient_scope", "forbidden", "request_invalid", diff --git a/packages/cultnet-ts/test/governance-vectors.test.ts b/packages/cultnet-ts/test/governance-vectors.test.ts index 38e41b4d..4dc9b7b6 100644 --- a/packages/cultnet-ts/test/governance-vectors.test.ts +++ b/packages/cultnet-ts/test/governance-vectors.test.ts @@ -504,6 +504,46 @@ test("fork transport: order 0 is legal, no cursor names it, and the page ending assert.equal(check(forkSchema, "cursor", "c0"), false); }); +test("fork transport: a page may stop short of limit, and a first entry past the budget is fork_too_large (422) continued below its order", () => { + const sc = fork.transport.listShortPages; + assert.deepEqual(sc.map((x: any) => x.name), ["a_short_page_resumes_below_its_last_entry", "a_first_entry_past_the_budget_is_fork_too_large_and_the_client_continues_below_its_order", "fork_too_large_on_order_zero_leaves_nothing_below"]); + assert.ok((forkSchema.$defs.refusal.properties.code as { enum: string[] }).enum.includes("fork_too_large")); + const covered: number[] = []; + for (const s of sc) { + let last: string | null | undefined = undefined; + s.steps.forEach((step: any, i: number) => { + if (last !== undefined) assert.equal(step.request.cursor, last, `${s.name}: the step follows the cursor or the refused order`); + const full = judgeList(s.entries, s.ordinal, step.request); + if (step.response !== undefined) { + const r = step.response as any, n = r.forks.length; + assert.ok(check(forkSchema, "list", r), s.name); + assert.equal(hexOf(r), step.responseHex, s.name); + assert.deepEqual(r.forks, full.forks.slice(0, n), `${s.name}: a prefix of the unbudgeted page`); + assert.ok(n >= 1, s.name); + assert.equal(r.next_cursor, full.next_cursor !== null || n < full.forks.length ? `c${r.forks[n - 1].card.order}` : null, s.name); + assert.equal(r.stamp, full.stamp); assert.equal(r.open_count, full.open_count); assert.equal(r.recorded_count, full.recorded_count); + covered.push(...r.forks.map((f: any) => f.card.order)); + last = r.next_cursor; + } else { + const r = step.refusal, first = full.forks[0].card as any; + assert.ok(check(forkSchema, "refusal", r), s.name); + assert.equal(hexOf(r), step.refusalHex, s.name); + assert.equal(r.code, "fork_too_large"); + assert.equal(step.httpStatus, 422); + assert.equal(step.fork_id, first.fork_id); assert.equal(step.order, first.order); + assert.equal(r.error, `fork_too_large: fork ${first.fork_id} (order ${first.order}) alone is past the response budget; read it by id`); + assert.ok(step.request.limit >= 1, "limit 0 never refuses"); + if (first.order >= 1) { assert.ok(i + 1 < s.steps.length, s.name); last = `c${first.order}`; assert.ok(check(forkSchema, "cursor", last)); } + else { assert.equal(i, s.steps.length - 1, `${s.name}: order 0 is the end`); assert.equal(check(forkSchema, "cursor", "c0"), false); last = undefined; } + } + }); + } + assert.deepEqual(sc[0].steps.map((x: any) => x.response.next_cursor), ["c2", null]); + assert.deepEqual(sc[0].steps.map((x: any) => x.response.forks.length), [2, 2]); + assert.deepEqual([sc[1].steps[0].order, sc[2].steps[1].order], [3, 0]); + assert.deepEqual(covered, [3, 2, 1, 0, 2, 1, 0, 1]); +}); + test("fork transport: auth is bearer, then audience, then scope, and a votes token touches nothing", () => { const codes = (forkSchema.$defs.refusal.properties.code as { enum: string[] }).enum; const seen = new Set(); diff --git a/scripts/gen-governance-vectors.py b/scripts/gen-governance-vectors.py index b6b931d1..03f4f296 100644 --- a/scripts/gen-governance-vectors.py +++ b/scripts/gen-governance-vectors.py @@ -358,7 +358,7 @@ scen += [ sc("R1_a_target_less_retraction_with_retracted_flipped_is_body_invalid_before_any_request_id_lookup", CARD, [X1, rf(IL_B, sr("n2"), "bad_retraction"), door(dict(IL_B, retracted=False), sr("n9"), "body_invalid")], "n1"), ] REASON_CODES = ["stale_fork", "not_a_decider", "fork_closed", "choice_invalid", "bad_retraction", "already_retracted"] -HTTP = {"unauthenticated": 401, "body_forbidden_field": 400, "body_unknown_field": 400, "body_invalid": 400, "fork_unknown": 404, "insufficient_scope": 403, "forbidden": 403, "request_id_conflict": 409, "request_invalid": 400, +HTTP = {"unauthenticated": 401, "body_forbidden_field": 400, "body_unknown_field": 400, "body_invalid": 400, "fork_unknown": 404, "insufficient_scope": 403, "forbidden": 403, "request_id_conflict": 409, "request_invalid": 400, "fork_too_large": 422, "stale_fork": 409, "not_a_decider": 403, "fork_closed": 409, "choice_invalid": 400, "bad_retraction": 400, "already_retracted": 409} STORED_REFUSALS = [] for reason in REASON_CODES: @@ -422,6 +422,24 @@ LISTS = [ {"name": "order_zero_is_legal_but_no_cursor_names_it", "ordinal": 9, "entries": ORDER0, "steps": [lstep(ORDER0, 9, lreq(limit=3)), lstep(ORDER0, 9, lreq(cursor="c1", limit=3))]}, {"name": "a_mind_with_no_forks", "ordinal": 7, "entries": [], "steps": [lstep([], 7, lreq(mind="nibu", limit=10))]}, ] +# A page may stop short of `limit` when the next entry would pass the receiver's response budget; a list whose first +# entry alone passes it is refused `fork_too_large` (422). The budget is not in the vector: a step records what the receiver answered, and the +# judges check it against the unbudgeted page (a nonempty strict prefix, resumed below its last entry) or against the entry the page would open with. +def too_large_doc(c): return {"schemaVersion": "gamecult.fork.refusal.v0", "code": "fork_too_large", "error": f"fork_too_large: fork {c['fork_id']} (order {c['order']}) alone is past the response budget; read it by id"} +def short_step(entries, ordinal, rq, keep): + full = judge_list(entries, ordinal, rq); page = full["forks"][:keep] + assert 0 < keep < len(full["forks"]) + r = dict(full, forks=page, next_cursor=f"c{page[-1]['card']['order']}"); return {"request": rq, "response": r, "responseHex": hx(r)} +def large_step(entries, ordinal, rq): + c = judge_list(entries, ordinal, rq)["forks"][0]["card"]; r = too_large_doc(c) + return {"request": rq, "refusal": r, "refusalHex": hx(r), "httpStatus": HTTP["fork_too_large"], "fork_id": c["fork_id"], "order": c["order"]} +SHORT_PAGES = [ + {"name": "a_short_page_resumes_below_its_last_entry", "ordinal": 9, "entries": ORDER0, "steps": [short_step(ORDER0, 9, lreq(limit=3), 2), lstep(ORDER0, 9, lreq(cursor="c2", limit=3))]}, + {"name": "a_first_entry_past_the_budget_is_fork_too_large_and_the_client_continues_below_its_order", "ordinal": 9, "entries": ORDER0, + "steps": [large_step(ORDER0, 9, lreq(limit=3)), lstep(ORDER0, 9, lreq(cursor="c3", limit=3))]}, + {"name": "fork_too_large_on_order_zero_leaves_nothing_below", "ordinal": 9, "entries": ORDER0[2:], + "steps": [short_step(ORDER0[2:], 9, lreq(limit=5), 1), large_step(ORDER0[2:], 9, lreq(cursor="c1", limit=5))]}, +] DUP_LIST = judge_list(ENTRIES + [fentry(q4b)], 412, lreq(limit=50)) assert order_shared(DUP_LIST["forks"]) == ["order 4 is shared by gamecult-chat:question:q4 and gamecult-chat:question:q4b"] TOK = lambda aud, *sc, active=True: {"audience": aud, "scopes": list(sc), "active": active} @@ -586,7 +604,7 @@ fork = { ("no_answerer", "answerer is stamped on every stored answer", dict(CNT, answerer="")), ]]}, "results": [{"name": r["name"], "httpStatus": r["httpStatus"], "message": r["message"], "messagePackHex": hx(r["message"])} for r in STORED_REFUSALS], - "doorRefusals": [{"name": c, "httpStatus": HTTP[c], "message": refusal_doc(c), "messagePackHex": hx(refusal_doc(c))} for c in TAIL], + "doorRefusals": [{"name": c, "httpStatus": HTTP[c], "message": refusal_doc(c), "messagePackHex": hx(refusal_doc(c))} for c in TAIL] + [{"name": "fork_too_large", "httpStatus": HTTP["fork_too_large"], "message": SHORT_PAGES[1]["steps"][0]["refusal"], "messagePackHex": SHORT_PAGES[1]["steps"][0]["refusalHex"]}], "unavailable": [{"name": "second_door_down", "httpStatus": 503, "message": unavailable_doc(30), "messagePackHex": hx(unavailable_doc(30))}, {"name": "second_door_down_no_hint", "httpStatus": 503, "message": unavailable_doc(None), "messagePackHex": hx(unavailable_doc(None))}], "rulingCites": cites, @@ -618,6 +636,7 @@ fork = { + [dict(bad("two_cards_share_one_order", "rule: order is a unique rank per Mind; the refusal names both fork ids (not expressible in the schema)", DUP_LIST), messagePackHex=hx(DUP_LIST), refusal=order_shared(DUP_LIST["forks"])[0])], "listScenarios": [{"name": l["name"], "ordinal": l["ordinal"], "entries": l["entries"], "steps": l["steps"]} for l in LISTS], + "listShortPages": SHORT_PAGES, "mindRoutes": {"minds": MINDS, "forks": MIND_FORKS, "cases": MINDCASES, "sameResponse": SAME_RESPONSE}, "authCases": [{"name": a[0], "route": a[1], "token": a[2], "expect": a[3], "huginn_down": len(a) > 4} for a in AUTH], }, diff --git a/tests/GameCult.Networking.Tests/GovernanceVectorTests.cs b/tests/GameCult.Networking.Tests/GovernanceVectorTests.cs index d34e6fe6..84472cef 100644 --- a/tests/GameCult.Networking.Tests/GovernanceVectorTests.cs +++ b/tests/GameCult.Networking.Tests/GovernanceVectorTests.cs @@ -692,6 +692,55 @@ namespace GameCult.Networking.Tests Assert.That(Errors(ForkSchema, "cursor", JsonValue.Create("c0")), Is.Not.Empty); } + [Test] + public void ForkTransport_AShortPage_AndForkTooLarge422_ContinuedBelowItsOrder() + { + var sc = Fork["transport"]!["listShortPages"]!.AsArray(); + Assert.That(sc.Select(x => S(x!["name"])), Is.EqualTo(new[] { "a_short_page_resumes_below_its_last_entry", "a_first_entry_past_the_budget_is_fork_too_large_and_the_client_continues_below_its_order", "fork_too_large_on_order_zero_leaves_nothing_below" })); + var schema = JsonNode.Parse(File.ReadAllText(Path.Combine(RepoRoot(), "contracts", "cultnet", ForkSchema)))!; + Assert.That(schema["$defs"]!["refusal"]!["properties"]!["code"]!["enum"]!.AsArray().Select(S), Does.Contain("fork_too_large")); + var covered = new List(); + foreach (var s in sc) + { + var entries = s!["entries"]!.AsArray(); var steps = s["steps"]!.AsArray(); string? last = null; var follow = false; + for (var i = 0; i < steps.Count; i++) + { + var step = steps[i]!; var name = S(s["name"]); + if (follow) Assert.That(step["request"]!["cursor"] is null ? null : S(step["request"]!["cursor"]), Is.EqualTo(last), name); + var full = JudgeList(entries, s["ordinal"]!.GetValue(), step["request"]!); + var fullForks = full["forks"]!.AsArray(); + if (step["response"] is not null) + { + var r = step["response"]!; var forks = r["forks"]!.AsArray(); var n = forks.Count; + Assert.That(Errors(ForkSchema, "list", r), Is.Empty, name); + Assert.That(HexOf(r), Is.EqualTo(S(step["responseHex"])), name); + Assert.That(n, Is.GreaterThanOrEqualTo(1), name); + for (var k = 0; k < n; k++) Assert.That(JsonNode.DeepEquals(forks[k], fullForks[k]), Is.True, $"{name}: a prefix of the unbudgeted page"); + var o = forks[n - 1]!["card"]!["order"]!.GetValue(); + if (full["next_cursor"] is not null || n < fullForks.Count) Assert.That(S(r["next_cursor"]), Is.EqualTo($"c{o}"), name); else Assert.That(r["next_cursor"], Is.Null, name); + Assert.That(S(r["stamp"]), Is.EqualTo(S(full["stamp"]))); Assert.That(r["open_count"]!.GetValue(), Is.EqualTo(full["open_count"]!.GetValue())); Assert.That(r["recorded_count"]!.GetValue(), Is.EqualTo(full["recorded_count"]!.GetValue())); + covered.AddRange(forks.Select(f => f!["card"]!["order"]!.GetValue())); + last = r["next_cursor"] is null ? null : S(r["next_cursor"]); follow = true; + } + else + { + var r = step["refusal"]!; var first = fullForks[0]!["card"]!; + Assert.That(Errors(ForkSchema, "refusal", r), Is.Empty, name); + Assert.That(HexOf(r), Is.EqualTo(S(step["refusalHex"])), name); + Assert.That(S(r["code"]), Is.EqualTo("fork_too_large")); Assert.That(step["httpStatus"]!.GetValue(), Is.EqualTo(422)); + Assert.That(S(step["fork_id"]), Is.EqualTo(S(first["fork_id"]))); Assert.That(step["order"]!.GetValue(), Is.EqualTo(first["order"]!.GetValue())); + Assert.That(S(r["error"]), Is.EqualTo($"fork_too_large: fork {S(first["fork_id"])} (order {first["order"]!.GetValue()}) alone is past the response budget; read it by id")); + Assert.That(step["request"]!["limit"]!.GetValue(), Is.GreaterThanOrEqualTo(1)); + var order = first["order"]!.GetValue(); + if (order >= 1) { Assert.That(i + 1, Is.LessThan(steps.Count), name); last = $"c{order}"; follow = true; Assert.That(Errors(ForkSchema, "cursor", JsonValue.Create(last)), Is.Empty); } + else { Assert.That(i, Is.EqualTo(steps.Count - 1), $"{name}: order 0 is the end"); follow = false; } + } + } + } + Assert.That(covered, Is.EqualTo(new long[] { 3, 2, 1, 0, 2, 1, 0, 1 })); + Assert.That(sc[0]!["steps"]!.AsArray().Select(x => x!["response"]!["forks"]!.AsArray().Count), Is.EqualTo(new[] { 2, 2 })); + } + [Test] public void ForkTransport_Auth_BearerThenAudienceThenScope_AVotesTokenTouchesNothing() { From 17c70d290df9812bb00f9a1403d210f764d80bce Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 01:00:04 +0000 Subject: [PATCH 02/93] Fork card: request_id_conflict is answer-only; a door conflict on read or list is 503 unavailable Co-Authored-By: Claude Sonnet 5.5 --- .../cultnet/governance/fork-card-vectors.json | 23 +++++++++++++++++++ contracts/cultnet/governance/fork-card.md | 4 ++-- .../test/governance-vectors.test.ts | 9 ++++++++ scripts/gen-governance-vectors.py | 2 ++ .../GovernanceVectorTests.cs | 14 +++++++++++ 5 files changed, 50 insertions(+), 2 deletions(-) diff --git a/contracts/cultnet/governance/fork-card-vectors.json b/contracts/cultnet/governance/fork-card-vectors.json index 124cbbd3..99766e0f 100644 --- a/contracts/cultnet/governance/fork-card-vectors.json +++ b/contracts/cultnet/governance/fork-card-vectors.json @@ -7505,6 +7505,29 @@ ] } ], + "doorFaults": [ + { + "name": "read_door_answers_request_id_conflict_the_client_sees_unavailable", + "route": "read", + "doorCode": "request_id_conflict", + "clientCode": "unavailable", + "httpStatus": 503 + }, + { + "name": "list_door_answers_request_id_conflict_the_client_sees_unavailable", + "route": "list", + "doorCode": "request_id_conflict", + "clientCode": "unavailable", + "httpStatus": 503 + }, + { + "name": "answer_door_answers_request_id_conflict_the_client_sees_it", + "route": "answer", + "doorCode": "request_id_conflict", + "clientCode": "request_id_conflict", + "httpStatus": 409 + } + ], "mindRoutes": { "minds": { "gamecult-chat": "huginn-gamecult-chat.sock", diff --git a/contracts/cultnet/governance/fork-card.md b/contracts/cultnet/governance/fork-card.md index c5b16934..bf8aa7f4 100644 --- a/contracts/cultnet/governance/fork-card.md +++ b/contracts/cultnet/governance/fork-card.md @@ -118,7 +118,7 @@ Source: the desk's client needs (Ratatoskr PR #24 section 12, `ratatoskr-desk-sp Calls 1 and 2 are enough for a read-only desk. Every response is the pinned MessagePack document or its JSON form with the same keys (open choice 1 of `vote-api.md`: the media type is unsettled). -**Auth, in order, per route.** (1) A bearer, else `unauthenticated` (401). (2) The token's audience is `bifrost-desk`: **the Matrix Authentication Service token chat holds today is refused, and so is any `votes.*` token** (audience `bifrost-votes`), both as `unauthenticated`. (3) The scope the route needs, else `insufficient_scope` (403): `desk.read` for calls 1 and 2, `desk.answer` for call 3, and neither implies the other. (4) The account behind `sub` is an Active member, else `forbidden` (403), as in the vote API; scope is judged first. (5) For call 3 the body is then shaped (steps 1 and 2 above), so **a non-Active member who sends a bad body, including one naming `answerer` or `authority`, gets `forbidden`, not a body refusal**; a body naming either key at any depth is `body_forbidden_field` only for an Active member. (6) Only then is Huginn asked, so `unavailable` never hides an auth or body refusal. A `votes.*` token touches none of the three. Heimdall's file leaves the audience and the claims check open; `insufficient_scope`, `forbidden`, `request_invalid` (400, a list or read request that fails its document; an unknown key, `authority` included, is `request_invalid` here, whatever Huginn's own decode does with it) and `request_id_conflict` are added to the door refusals, and so is `fork_too_large` (422, list only; see List). +**Auth, in order, per route.** (1) A bearer, else `unauthenticated` (401). (2) The token's audience is `bifrost-desk`: **the Matrix Authentication Service token chat holds today is refused, and so is any `votes.*` token** (audience `bifrost-votes`), both as `unauthenticated`. (3) The scope the route needs, else `insufficient_scope` (403): `desk.read` for calls 1 and 2, `desk.answer` for call 3, and neither implies the other. (4) The account behind `sub` is an Active member, else `forbidden` (403), as in the vote API; scope is judged first. (5) For call 3 the body is then shaped (steps 1 and 2 above), so **a non-Active member who sends a bad body, including one naming `answerer` or `authority`, gets `forbidden`, not a body refusal**; a body naming either key at any depth is `body_forbidden_field` only for an Active member. (6) Only then is Huginn asked, so `unavailable` never hides an auth or body refusal. A `votes.*` token touches none of the three. Heimdall's file leaves the audience and the claims check open; `insufficient_scope`, `forbidden`, `request_invalid` (400, a list or read request that fails its document; an unknown key, `authority` included, is `request_invalid` here, whatever Huginn's own decode does with it) and `request_id_conflict` are added to the door refusals, and so is `fork_too_large` (422, list only; see List). **`request_id_conflict` is answer-only**: the idempotency key exists only on the answer. A door `request_id_conflict` on a read or list is a door fault and reaches the client as 503 `unavailable`, never as `request_id_conflict` (Persona, 2026-11-11, agreed with Heimdall; vectors `transport.doorFaults`). **List.** `list_request`: `{mind, status, cursor, limit}`. `mind` is the route's `{mind}`, a Mind id from the configured list. `status` is `open`, `recorded` or nil for both; `recorded` means a closed card. Cards come **newest first by `order`**. `order` is the card's unique rank per Mind: an integer from 0 that never changes once the card exists, later questions only appending (Huginn derives it from the Mind's questions sorted by admission ordinal, then key). A Mind never holds two cards of one `order`; a list or card set that does is refused, and the refusal names both fork ids. 0 is legal, but no cursor names it (`c0` fails the cursor pattern): a page ending at order 1 returns `c1`, and the next page returns the order-0 card alone with `next_cursor` nil. `limit` is 0 to 50; **`limit` 0 returns no entries, only the counts and the stamp**, which is what "N open" on the Mind's header needs. `cursor` is nil or `c`: the cards whose `order` is below N; a client treats it as opaque and sends back the `next_cursor` it was given. `list`: `{mind, stamp, open_count, recorded_count, forks[entry], next_cursor}`. The counts cover every card the viewer may see, whatever `status` and `cursor` asked for. `stamp` is `s`, N the receiver's last-seen Huginn admission ordinal for the Mind's desk documents: the same stamp means the same state, so a desk polls `limit` 0 and fetches bodies only when the stamp moves. `next_cursor` is nil on the last page. **A page may hold fewer than `limit` entries** when the next entry would push the encoded response past the receiver's response budget (Huginn: 65536 bytes); it then holds at least one, `next_cursor` is `c` so the next page resumes below it, and the counts and the stamp are those of the full list, unchanged. **`fork_too_large`** (HTTP 422, list only) refuses a page whose **first** entry alone is past the budget: 422 because the request is well formed and authorised but names a resource the list cannot carry, and 413 is for a request body that is too large, which this is not; retrying returns the same refusal. Its `error` names the fork id and its `order` (`fork_too_large: fork (order ) alone is past the response budget; read it by id`). The client reads that fork with the read route, which always returns the whole entry and never this refusal, and continues the list with cursor `c`, the cards strictly below that order; `c` is valid only when n is at least 1, so for order 0 there is nothing below and the client has reached the end. `limit` 0 returns no entries and never refuses. (Ruled by Persona, 2026-11-11.) An `entry` is `{card, record}`: the card and its `record` (the answers with their outcomes and the derived `counted_answer_id`). @@ -126,7 +126,7 @@ Calls 1 and 2 are enough for a read-only desk. Every response is the pinned Mess **Answer.** The body is the `answer_request` and nothing else a client may set: `fork_id`, `options_hash`, `answer`, `answered_at`, `request_id` (plus `retracted` and `retracts`, written `false` and nil unless the call retracts). The response is the stored outcome: `Counted`, `Revision`, `Retraction`, or `Refused` with its code, and the **`counted_answer_id`** the fork has once this call is applied (`n` or nil; derived as in "The counted answer is derived", the same value the `record` projection carries, so the desk needs no second read). A replay returns the stored outcome with the `counted_answer_id` current at the retry, not the one it had when first stored. **A `stale_fork` refusal carries the current card** in `answer_result.card`, so the desk can show the new fork without another read. Nothing is stored for a step 1 or 2 refusal, an unknown fork (or an unknown Mind, or a fork another Mind holds) or a `request_id_conflict`. `unavailable` promises nothing either way. -**Vectors (`transport` in `fork-card-vectors.json`).** `listRequest`, `readRequest` and `entry` (valid and invalid, with bytes); `listInvalid` (including `two_cards_share_one_order`, whose `refusal` is the exact text both judges must produce: `order 4 is shared by and `); `listScenarios` (six scenarios: over one Mind's five cards, paged open-only, recorded-only, all and `limit` 0; an empty Mind; and `order_zero_is_legal_but_no_cursor_names_it`, four cards of orders 3 to 0 paged by 3, whose first page ends at order 1 with `c1` and whose second page is the order-0 card with `next_cursor` nil; each response with its bytes, the cursor of page two being page one's `next_cursor`); `listShortPages` (three scenarios: a page that stops short and resumes below its last entry; `fork_too_large` on a first entry of order 3, continued with `c3`; and a short page ending at order 1 followed by `fork_too_large` on order 0, where the client is at the end; each step carries its bytes, and the receiver's budget is in no vector, so the judges check a short page as a nonempty strict prefix of the unbudgeted page and a refusal as naming the entry the page would open with); `authCases` (twenty-two cases: the MAS token, three `votes.*` tokens, a desk token with the other route's scope, a non-Active member on each route, and Huginn down on each route, with auth and membership judged first). Both suites replay the list requests through a reference judge and the auth cases through another. `mindRoutes` (a configured list of two Minds with their sockets and forks; 57 cases over the three routes: bad and percent-encoded segments, unknown Mind against unknown fork with `sameResponse` pairs of equal bytes, the POST on a Mind that does not hold the fork, scopes that are not per Mind, and auth judged before the Mind lookup) is replayed by both suites through a reference judge that reports the refusal, the socket it would open and the Mind that stored an answer. Top-level `unavailable` pins the 503 document. Scenarios pin: an `unavailable` where Huginn never had the request, retried into a `Counted` answer; an `unavailable` where Huginn had stored it, whose retry returns the stored outcome and adds no Revision; a replayed `Refused` with its card; `request_id_conflict`; the same id from another account; and a non-Active member with a bad body, with the door down, getting `forbidden` first. A bad body is refused before the door is asked. +**Vectors (`transport` in `fork-card-vectors.json`).** `listRequest`, `readRequest` and `entry` (valid and invalid, with bytes); `listInvalid` (including `two_cards_share_one_order`, whose `refusal` is the exact text both judges must produce: `order 4 is shared by and `); `listScenarios` (six scenarios: over one Mind's five cards, paged open-only, recorded-only, all and `limit` 0; an empty Mind; and `order_zero_is_legal_but_no_cursor_names_it`, four cards of orders 3 to 0 paged by 3, whose first page ends at order 1 with `c1` and whose second page is the order-0 card with `next_cursor` nil; each response with its bytes, the cursor of page two being page one's `next_cursor`); `listShortPages` (three scenarios: a page that stops short and resumes below its last entry; `fork_too_large` on a first entry of order 3, continued with `c3`; and a short page ending at order 1 followed by `fork_too_large` on order 0, where the client is at the end; each step carries its bytes, and the receiver's budget is in no vector, so the judges check a short page as a nonempty strict prefix of the unbudgeted page and a refusal as naming the entry the page would open with); `doorFaults` (three cases: a door `request_id_conflict` is `unavailable` 503 on read and list and itself 409 on the answer); `authCases` (twenty-two cases: the MAS token, three `votes.*` tokens, a desk token with the other route's scope, a non-Active member on each route, and Huginn down on each route, with auth and membership judged first). Both suites replay the list requests through a reference judge and the auth cases through another. `mindRoutes` (a configured list of two Minds with their sockets and forks; 57 cases over the three routes: bad and percent-encoded segments, unknown Mind against unknown fork with `sameResponse` pairs of equal bytes, the POST on a Mind that does not hold the fork, scopes that are not per Mind, and auth judged before the Mind lookup) is replayed by both suites through a reference judge that reports the refusal, the socket it would open and the Mind that stored an answer. Top-level `unavailable` pins the 503 document. Scenarios pin: an `unavailable` where Huginn never had the request, retried into a `Counted` answer; an `unavailable` where Huginn had stored it, whose retry returns the stored outcome and adds no Revision; a replayed `Refused` with its card; `request_id_conflict`; the same id from another account; and a non-Active member with a bad body, with the door down, getting `forbidden` first. A bad body is refused before the door is asked. **Bifrost must still build:** all three routes; the `bifrost-desk` audience and the `desk.read` and `desk.answer` scopes on the OIDC token it issues to chat; the configured Mind list and its sockets (M1); the projection of cards and records from Huginn, the stamp, the cursor and the counts; the `DeskAnswer` call on the second door and the pass-through of its outcome; the decider list, sent with every answer, read and list request from the admin-set list only (M5); the `unavailable` answer. **Huginn must still build** the second door's wire, the per-call decider list it receives with each answer, read and list request, keeping no copy (M5), and the read side the projection uses (Eureka's cut spec, after her record PR). **The desk may build today** against the vectors: nothing in them needs a live service. diff --git a/packages/cultnet-ts/test/governance-vectors.test.ts b/packages/cultnet-ts/test/governance-vectors.test.ts index 4dc9b7b6..c639ec73 100644 --- a/packages/cultnet-ts/test/governance-vectors.test.ts +++ b/packages/cultnet-ts/test/governance-vectors.test.ts @@ -544,6 +544,15 @@ test("fork transport: a page may stop short of limit, and a first entry past the assert.deepEqual(covered, [3, 2, 1, 0, 2, 1, 0, 1]); }); +test("fork transport: a door request_id_conflict is a fault on read and list (503 unavailable) and a refusal only on the answer", () => { + assert.deepEqual(fork.transport.doorFaults.map((c: any) => c.route), ["read", "list", "answer"]); + for (const c of fork.transport.doorFaults) { + const got = c.route === "answer" ? c.doorCode : c.doorCode === "request_id_conflict" ? "unavailable" : c.doorCode; + assert.equal(c.clientCode, got, c.name); + assert.equal(c.httpStatus, got === "unavailable" ? fork.unavailable[0].httpStatus : fork.doorRefusals.find((r: any) => r.name === got).httpStatus, c.name); + } +}); + test("fork transport: auth is bearer, then audience, then scope, and a votes token touches nothing", () => { const codes = (forkSchema.$defs.refusal.properties.code as { enum: string[] }).enum; const seen = new Set(); diff --git a/scripts/gen-governance-vectors.py b/scripts/gen-governance-vectors.py index 03f4f296..ecb6b313 100644 --- a/scripts/gen-governance-vectors.py +++ b/scripts/gen-governance-vectors.py @@ -637,6 +637,8 @@ fork = { messagePackHex=hx(DUP_LIST), refusal=order_shared(DUP_LIST["forks"])[0])], "listScenarios": [{"name": l["name"], "ordinal": l["ordinal"], "entries": l["entries"], "steps": l["steps"]} for l in LISTS], "listShortPages": SHORT_PAGES, + "doorFaults": [{"name": f"{r}_door_answers_request_id_conflict_the_client_sees_unavailable", "route": r, "doorCode": "request_id_conflict", "clientCode": "unavailable", "httpStatus": 503} for r in ("read", "list")] + + [{"name": "answer_door_answers_request_id_conflict_the_client_sees_it", "route": "answer", "doorCode": "request_id_conflict", "clientCode": "request_id_conflict", "httpStatus": HTTP["request_id_conflict"]}], "mindRoutes": {"minds": MINDS, "forks": MIND_FORKS, "cases": MINDCASES, "sameResponse": SAME_RESPONSE}, "authCases": [{"name": a[0], "route": a[1], "token": a[2], "expect": a[3], "huginn_down": len(a) > 4} for a in AUTH], }, diff --git a/tests/GameCult.Networking.Tests/GovernanceVectorTests.cs b/tests/GameCult.Networking.Tests/GovernanceVectorTests.cs index 84472cef..df674a75 100644 --- a/tests/GameCult.Networking.Tests/GovernanceVectorTests.cs +++ b/tests/GameCult.Networking.Tests/GovernanceVectorTests.cs @@ -741,6 +741,20 @@ namespace GameCult.Networking.Tests Assert.That(sc[0]!["steps"]!.AsArray().Select(x => x!["response"]!["forks"]!.AsArray().Count), Is.EqualTo(new[] { 2, 2 })); } + [Test] + public void ForkTransport_ADoorRequestIdConflict_IsAFaultOnReadAndList_AndARefusalOnlyOnTheAnswer() + { + var cases = Fork["transport"]!["doorFaults"]!.AsArray(); + Assert.That(cases.Select(c => S(c!["route"])), Is.EqualTo(new[] { "read", "list", "answer" })); + foreach (var c in cases) + { + var got = S(c!["route"]) == "answer" ? S(c["doorCode"]) : "unavailable"; + Assert.That(S(c["clientCode"]), Is.EqualTo(got), S(c["name"])); + var status = got == "unavailable" ? 503 : Fork["doorRefusals"]!.AsArray().Single(r => S(r!["name"]) == got)!["httpStatus"]!.GetValue(); + Assert.That(c["httpStatus"]!.GetValue(), Is.EqualTo(status), S(c["name"])); + } + } + [Test] public void ForkTransport_Auth_BearerThenAudienceThenScope_AVotesTokenTouchesNothing() { From 9394be3dd37354a3d722909a139962df9809c49a Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 01:27:28 +0000 Subject: [PATCH 03/93] Fork card: fork_too_large also refuses a read past the reply cap; door-fault bytes, 422 asserted in both judges Co-Authored-By: Claude Sonnet 5.5 --- .../cultnet/governance/fork-card-vectors.json | 238 +++++++++++++++++- contracts/cultnet/governance/fork-card.md | 11 +- .../test/governance-vectors.test.ts | 26 +- scripts/gen-governance-vectors.py | 12 +- .../GovernanceVectorTests.cs | 31 ++- 5 files changed, 289 insertions(+), 29 deletions(-) diff --git a/contracts/cultnet/governance/fork-card-vectors.json b/contracts/cultnet/governance/fork-card-vectors.json index 99766e0f..5f5e7c22 100644 --- a/contracts/cultnet/governance/fork-card-vectors.json +++ b/contracts/cultnet/governance/fork-card-vectors.json @@ -2699,9 +2699,9 @@ "message": { "schemaVersion": "gamecult.fork.refusal.v0", "code": "fork_too_large", - "error": "fork_too_large: fork gamecult-chat:question:q3 (order 3) alone is past the response budget; read it by id" + "error": "fork_too_large: fork gamecult-chat:question:q3 (order 3) alone is past the response budget" }, - "messagePackHex": "83AD736368656D6156657273696F6EB867616D6563756C742E666F726B2E7265667573616C2E7630A4636F6465AE666F726B5F746F6F5F6C61726765A56572726F72D969666F726B5F746F6F5F6C617267653A20666F726B2067616D6563756C742D636861743A7175657374696F6E3A713320286F7264657220332920616C6F6E6520697320706173742074686520726573706F6E7365206275646765743B2072656164206974206279206964" + "messagePackHex": "83AD736368656D6156657273696F6EB867616D6563756C742E666F726B2E7265667573616C2E7630A4636F6465AE666F726B5F746F6F5F6C61726765A56572726F72D95A666F726B5F746F6F5F6C617267653A20666F726B2067616D6563756C742D636861743A7175657374696F6E3A713320286F7264657220332920616C6F6E6520697320706173742074686520726573706F6E736520627564676574" } ], "unavailable": [ @@ -7165,9 +7165,9 @@ "refusal": { "schemaVersion": "gamecult.fork.refusal.v0", "code": "fork_too_large", - "error": "fork_too_large: fork gamecult-chat:question:q3 (order 3) alone is past the response budget; read it by id" + "error": "fork_too_large: fork gamecult-chat:question:q3 (order 3) alone is past the response budget" }, - "refusalHex": "83AD736368656D6156657273696F6EB867616D6563756C742E666F726B2E7265667573616C2E7630A4636F6465AE666F726B5F746F6F5F6C61726765A56572726F72D969666F726B5F746F6F5F6C617267653A20666F726B2067616D6563756C742D636861743A7175657374696F6E3A713320286F7264657220332920616C6F6E6520697320706173742074686520726573706F6E7365206275646765743B2072656164206974206279206964", + "refusalHex": "83AD736368656D6156657273696F6EB867616D6563756C742E666F726B2E7265667573616C2E7630A4636F6465AE666F726B5F746F6F5F6C61726765A56572726F72D95A666F726B5F746F6F5F6C617267653A20666F726B2067616D6563756C742D636861743A7175657374696F6E3A713320286F7264657220332920616C6F6E6520697320706173742074686520726573706F6E736520627564676574", "httpStatus": 422, "fork_id": "gamecult-chat:question:q3", "order": 3 @@ -7495,9 +7495,9 @@ "refusal": { "schemaVersion": "gamecult.fork.refusal.v0", "code": "fork_too_large", - "error": "fork_too_large: fork gamecult-chat:question:q0 (order 0) alone is past the response budget; read it by id" + "error": "fork_too_large: fork gamecult-chat:question:q0 (order 0) alone is past the response budget" }, - "refusalHex": "83AD736368656D6156657273696F6EB867616D6563756C742E666F726B2E7265667573616C2E7630A4636F6465AE666F726B5F746F6F5F6C61726765A56572726F72D969666F726B5F746F6F5F6C617267653A20666F726B2067616D6563756C742D636861743A7175657374696F6E3A713020286F7264657220302920616C6F6E6520697320706173742074686520726573706F6E7365206275646765743B2072656164206974206279206964", + "refusalHex": "83AD736368656D6156657273696F6EB867616D6563756C742E666F726B2E7265667573616C2E7630A4636F6465AE666F726B5F746F6F5F6C61726765A56572726F72D95A666F726B5F746F6F5F6C617267653A20666F726B2067616D6563756C742D636861743A7175657374696F6E3A713020286F7264657220302920616C6F6E6520697320706173742074686520726573706F6E736520627564676574", "httpStatus": 422, "fork_id": "gamecult-chat:question:q0", "order": 0 @@ -7505,27 +7505,241 @@ ] } ], + "readTooLarge": [ + { + "name": "a_read_whose_entry_is_past_the_cap_is_fork_too_large_without_an_order", + "ordinal": 9, + "entries": [ + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q3", + "title": "Fork 3", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q3", + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", + "counted_answer_id": null, + "answers": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q2", + "title": "Fork 2", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 2, + "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q2", + "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79", + "counted_answer_id": null, + "answers": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q1", + "title": "Fork 1", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 1, + "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q1", + "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a", + "counted_answer_id": null, + "answers": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q0", + "title": "Fork 0", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 0, + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q0", + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d", + "counted_answer_id": null, + "answers": [] + } + } + ], + "request": { + "schemaVersion": "gamecult.fork.read_request.v0", + "mind": "gamecult-chat", + "fork_id": "gamecult-chat:question:q3" + }, + "refusal": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "fork_too_large", + "error": "fork_too_large: fork gamecult-chat:question:q3 alone is past the response budget" + }, + "refusalHex": "83AD736368656D6156657273696F6EB867616D6563756C742E666F726B2E7265667573616C2E7630A4636F6465AE666F726B5F746F6F5F6C61726765A56572726F72D950666F726B5F746F6F5F6C617267653A20666F726B2067616D6563756C742D636861743A7175657374696F6E3A713320616C6F6E6520697320706173742074686520726573706F6E736520627564676574", + "httpStatus": 422, + "fork_id": "gamecult-chat:question:q3" + } + ], "doorFaults": [ { "name": "read_door_answers_request_id_conflict_the_client_sees_unavailable", "route": "read", "doorCode": "request_id_conflict", - "clientCode": "unavailable", - "httpStatus": 503 + "httpStatus": 503, + "response": { + "schemaVersion": "gamecult.fork.unavailable.v0", + "code": "unavailable", + "error": "unavailable: the outcome is not confirmed, try again with the same request_id", + "retryAfterSeconds": 30 + }, + "responseHex": "84AD736368656D6156657273696F6EBC67616D6563756C742E666F726B2E756E617661696C61626C652E7630A4636F6465AB756E617661696C61626C65A56572726F72D94D756E617661696C61626C653A20746865206F7574636F6D65206973206E6F7420636F6E6669726D65642C2074727920616761696E2077697468207468652073616D6520726571756573745F6964B1726574727941667465725365636F6E64731E" }, { "name": "list_door_answers_request_id_conflict_the_client_sees_unavailable", "route": "list", "doorCode": "request_id_conflict", - "clientCode": "unavailable", - "httpStatus": 503 + "httpStatus": 503, + "response": { + "schemaVersion": "gamecult.fork.unavailable.v0", + "code": "unavailable", + "error": "unavailable: the outcome is not confirmed, try again with the same request_id", + "retryAfterSeconds": 30 + }, + "responseHex": "84AD736368656D6156657273696F6EBC67616D6563756C742E666F726B2E756E617661696C61626C652E7630A4636F6465AB756E617661696C61626C65A56572726F72D94D756E617661696C61626C653A20746865206F7574636F6D65206973206E6F7420636F6E6669726D65642C2074727920616761696E2077697468207468652073616D6520726571756573745F6964B1726574727941667465725365636F6E64731E" }, { "name": "answer_door_answers_request_id_conflict_the_client_sees_it", "route": "answer", "doorCode": "request_id_conflict", - "clientCode": "request_id_conflict", - "httpStatus": 409 + "httpStatus": 409, + "response": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "request_id_conflict", + "error": "request_id_conflict: this request_id was used for a different body" + }, + "responseHex": "83AD736368656D6156657273696F6EB867616D6563756C742E666F726B2E7265667573616C2E7630A4636F6465B3726571756573745F69645F636F6E666C696374A56572726F72D942726571756573745F69645F636F6E666C6963743A207468697320726571756573745F696420776173207573656420666F72206120646966666572656E7420626F6479" } ], "mindRoutes": { diff --git a/contracts/cultnet/governance/fork-card.md b/contracts/cultnet/governance/fork-card.md index bf8aa7f4..0c1574d9 100644 --- a/contracts/cultnet/governance/fork-card.md +++ b/contracts/cultnet/governance/fork-card.md @@ -105,7 +105,7 @@ Source: the desk's client needs (Ratatoskr PR #24 section 12, `ratatoskr-desk-sp | call | route (proposal) | request | response | token | |---|---|---|---|---| | 1 list | `GET /api/desk/{mind}/forks?status=&cursor=&limit=` | `list_request`: `mind` from the path, the rest from the query string | `list` | audience `bifrost-desk`, scope `desk.read` | -| 2 read | `GET /api/desk/{mind}/forks/{fork_id}` (the id percent-encoded) | `read_request`, its `mind` and `fork_id` from the path | `entry`, or `refusal` `fork_unknown` (404) | `desk.read` | +| 2 read | `GET /api/desk/{mind}/forks/{fork_id}` (the id percent-encoded) | `read_request`, its `mind` and `fork_id` from the path | `entry`, or `refusal` `fork_unknown` (404) or `fork_too_large` (422) | `desk.read` | | 3 answer | `POST /api/desk/{mind}/answers` | `answer_request` as the body; `fork_id` stays in the body, the Mind is the route's and is no body field | `answer_result` (200 for `Counted`, `Revision` and `Retraction`; a stored `Refused` answer keeps the `answer_result` body and takes its code's status from the counting-rules table: `stale_fork` 409, `not_a_decider` 403, `fork_closed` 409, `choice_invalid` 400, `bad_retraction` 400, `already_retracted` 409, as the vectors' `results` pin; a replay returns the same status as the first call; the desk reads `outcome` and `code` from the body), or a `refusal`, or `unavailable` | audience `bifrost-desk`, scope `desk.answer` | **The `{mind}` segment** (Heimdall's rulings M1 to M5; vectors `transport.mindRoutes`). @@ -118,15 +118,15 @@ Source: the desk's client needs (Ratatoskr PR #24 section 12, `ratatoskr-desk-sp Calls 1 and 2 are enough for a read-only desk. Every response is the pinned MessagePack document or its JSON form with the same keys (open choice 1 of `vote-api.md`: the media type is unsettled). -**Auth, in order, per route.** (1) A bearer, else `unauthenticated` (401). (2) The token's audience is `bifrost-desk`: **the Matrix Authentication Service token chat holds today is refused, and so is any `votes.*` token** (audience `bifrost-votes`), both as `unauthenticated`. (3) The scope the route needs, else `insufficient_scope` (403): `desk.read` for calls 1 and 2, `desk.answer` for call 3, and neither implies the other. (4) The account behind `sub` is an Active member, else `forbidden` (403), as in the vote API; scope is judged first. (5) For call 3 the body is then shaped (steps 1 and 2 above), so **a non-Active member who sends a bad body, including one naming `answerer` or `authority`, gets `forbidden`, not a body refusal**; a body naming either key at any depth is `body_forbidden_field` only for an Active member. (6) Only then is Huginn asked, so `unavailable` never hides an auth or body refusal. A `votes.*` token touches none of the three. Heimdall's file leaves the audience and the claims check open; `insufficient_scope`, `forbidden`, `request_invalid` (400, a list or read request that fails its document; an unknown key, `authority` included, is `request_invalid` here, whatever Huginn's own decode does with it) and `request_id_conflict` are added to the door refusals, and so is `fork_too_large` (422, list only; see List). **`request_id_conflict` is answer-only**: the idempotency key exists only on the answer. A door `request_id_conflict` on a read or list is a door fault and reaches the client as 503 `unavailable`, never as `request_id_conflict` (Persona, 2026-11-11, agreed with Heimdall; vectors `transport.doorFaults`). +**Auth, in order, per route.** (1) A bearer, else `unauthenticated` (401). (2) The token's audience is `bifrost-desk`: **the Matrix Authentication Service token chat holds today is refused, and so is any `votes.*` token** (audience `bifrost-votes`), both as `unauthenticated`. (3) The scope the route needs, else `insufficient_scope` (403): `desk.read` for calls 1 and 2, `desk.answer` for call 3, and neither implies the other. (4) The account behind `sub` is an Active member, else `forbidden` (403), as in the vote API; scope is judged first. (5) For call 3 the body is then shaped (steps 1 and 2 above), so **a non-Active member who sends a bad body, including one naming `answerer` or `authority`, gets `forbidden`, not a body refusal**; a body naming either key at any depth is `body_forbidden_field` only for an Active member. (6) Only then is Huginn asked, so `unavailable` never hides an auth or body refusal. A `votes.*` token touches none of the three. Heimdall's file leaves the audience and the claims check open; `insufficient_scope`, `forbidden`, `request_invalid` (400, a list or read request that fails its document; an unknown key, `authority` included, is `request_invalid` here, whatever Huginn's own decode does with it) and `request_id_conflict` are added to the door refusals, and so is `fork_too_large` (422, list and read; see List and Read). **`request_id_conflict` is answer-only**: the idempotency key exists only on the answer. A door `request_id_conflict` on a read or list is a door fault and reaches the client as 503 `unavailable`, never as `request_id_conflict` (Persona, 2026-11-11, agreed with Heimdall; vectors `transport.doorFaults`, each carrying the client response's bytes). The `unavailable` text's "same request_id" retry hint is for answers; reads and lists carry no request_id.. -**List.** `list_request`: `{mind, status, cursor, limit}`. `mind` is the route's `{mind}`, a Mind id from the configured list. `status` is `open`, `recorded` or nil for both; `recorded` means a closed card. Cards come **newest first by `order`**. `order` is the card's unique rank per Mind: an integer from 0 that never changes once the card exists, later questions only appending (Huginn derives it from the Mind's questions sorted by admission ordinal, then key). A Mind never holds two cards of one `order`; a list or card set that does is refused, and the refusal names both fork ids. 0 is legal, but no cursor names it (`c0` fails the cursor pattern): a page ending at order 1 returns `c1`, and the next page returns the order-0 card alone with `next_cursor` nil. `limit` is 0 to 50; **`limit` 0 returns no entries, only the counts and the stamp**, which is what "N open" on the Mind's header needs. `cursor` is nil or `c`: the cards whose `order` is below N; a client treats it as opaque and sends back the `next_cursor` it was given. `list`: `{mind, stamp, open_count, recorded_count, forks[entry], next_cursor}`. The counts cover every card the viewer may see, whatever `status` and `cursor` asked for. `stamp` is `s`, N the receiver's last-seen Huginn admission ordinal for the Mind's desk documents: the same stamp means the same state, so a desk polls `limit` 0 and fetches bodies only when the stamp moves. `next_cursor` is nil on the last page. **A page may hold fewer than `limit` entries** when the next entry would push the encoded response past the receiver's response budget (Huginn: 65536 bytes); it then holds at least one, `next_cursor` is `c` so the next page resumes below it, and the counts and the stamp are those of the full list, unchanged. **`fork_too_large`** (HTTP 422, list only) refuses a page whose **first** entry alone is past the budget: 422 because the request is well formed and authorised but names a resource the list cannot carry, and 413 is for a request body that is too large, which this is not; retrying returns the same refusal. Its `error` names the fork id and its `order` (`fork_too_large: fork (order ) alone is past the response budget; read it by id`). The client reads that fork with the read route, which always returns the whole entry and never this refusal, and continues the list with cursor `c`, the cards strictly below that order; `c` is valid only when n is at least 1, so for order 0 there is nothing below and the client has reached the end. `limit` 0 returns no entries and never refuses. (Ruled by Persona, 2026-11-11.) An `entry` is `{card, record}`: the card and its `record` (the answers with their outcomes and the derived `counted_answer_id`). +**List.** `list_request`: `{mind, status, cursor, limit}`. `mind` is the route's `{mind}`, a Mind id from the configured list. `status` is `open`, `recorded` or nil for both; `recorded` means a closed card. Cards come **newest first by `order`**. `order` is the card's unique rank per Mind: an integer from 0 that never changes once the card exists, later questions only appending (Huginn derives it from the Mind's questions sorted by admission ordinal, then key). A Mind never holds two cards of one `order`; a list or card set that does is refused, and the refusal names both fork ids. 0 is legal, but no cursor names it (`c0` fails the cursor pattern): a page ending at order 1 returns `c1`, and the next page returns the order-0 card alone with `next_cursor` nil. `limit` is 0 to 50; **`limit` 0 returns no entries, only the counts and the stamp**, which is what "N open" on the Mind's header needs. `cursor` is nil or `c`: the cards whose `order` is below N; a client treats it as opaque and sends back the `next_cursor` it was given. `list`: `{mind, stamp, open_count, recorded_count, forks[entry], next_cursor}`. The counts cover every card the viewer may see, whatever `status` and `cursor` asked for. `stamp` is `s`, N the receiver's last-seen Huginn admission ordinal for the Mind's desk documents: the same stamp means the same state, so a desk polls `limit` 0 and fetches bodies only when the stamp moves. `next_cursor` is nil on the last page. **A page may hold fewer than `limit` entries** when the next entry would push the encoded response past the receiver's response budget (Huginn: 65536 bytes); it then holds at least one, `next_cursor` is `c` so the next page resumes below it, and the counts and the stamp are those of the full list, unchanged. **`fork_too_large`** (HTTP 422) refuses a page whose **first** entry alone is past the budget: 422 because the request is well formed and authorised but names a resource the list cannot carry, and 413 is for a request body that is too large, which this is not; retrying returns the same refusal. Its `error` names the fork id and its `order` (`fork_too_large: fork (order ) alone is past the response budget`). The client continues the list with cursor `c`, the cards strictly below that order, which skips this fork; it is then readable only by id (the read route returns the whole entry, and refuses with the same code only when that entry alone is past the cap, see Read); `c` is valid only when n is at least 1, so for order 0 there is nothing below and the client has reached the end. `limit` 0 returns no entries and never refuses. (Ruled by Persona, 2026-11-11.) An `entry` is `{card, record}`: the card and its `record` (the answers with their outcomes and the derived `counted_answer_id`). -**Read.** `read_request` is `{mind, fork_id}`; the response is the `entry` for one fork of that Mind. Its `card.options_hash` is **computed by the server** from the five shown members as in "Options hash" and the client recomputes it from what it rendered; a card whose hash is not the hash of its members is no card (negative vector `card_hash_wrong`). The record's `fork_id` and `options_hash` are the card's, and `counted_answer_id` is derived, not copied (negative vectors for each). Every `desk.read` holder sees every fork's card and outcome in a configured Mind; only a decider's notes depend on the caller, going to callers in the admin-set `deciders` list, which Bifrost enforces at the door (ruling `desk-read-visibility`, option (c)); `fork_unknown` is only for a fork id that does not exist under the route's Mind (an unknown Mind answers the same, M2), so no fork is ever hidden by it, and a caller without `desk.read` is refused by the auth order above (`insufficient_scope`) before any lookup. **Bifrost applies visibility at the door; Huginn returns the whole record to the door.** Every `desk.read` holder sees cards and outcomes; notes (a decider's own words) only go to callers in the admin-set `deciders` list. The read and list shapes are unchanged and carry no audience field (`eureka-substrate:question:desk-read-visibility`, Heimdall's option (c)). A stripped read serves a decider's `note` as `note: null`, never omitted (map-mode: every key is written); `desk_answer.note` is already nullable in the schema, so a served record validates as it stands. +**Read.** `read_request` is `{mind, fork_id}`; the response is the `entry` for one fork of that Mind. Its `card.options_hash` is **computed by the server** from the five shown members as in "Options hash" and the client recomputes it from what it rendered; a card whose hash is not the hash of its members is no card (negative vector `card_hash_wrong`). The record's `fork_id` and `options_hash` are the card's, and `counted_answer_id` is derived, not copied (negative vectors for each). Every `desk.read` holder sees every fork's card and outcome in a configured Mind; only a decider's notes depend on the caller, going to callers in the admin-set `deciders` list, which Bifrost enforces at the door (ruling `desk-read-visibility`, option (c)); `fork_unknown` is only for a fork id that does not exist under the route's Mind (an unknown Mind answers the same, M2), so no fork is ever hidden by it, and a caller without `desk.read` is refused by the auth order above (`insufficient_scope`) before any lookup. **Bifrost applies visibility at the door; Huginn returns the whole record to the door.** Every `desk.read` holder sees cards and outcomes; notes (a decider's own words) only go to callers in the admin-set `deciders` list. The read and list shapes are unchanged and carry no audience field (`eureka-substrate:question:desk-read-visibility`, Heimdall's option (c)). **A read is refused `fork_too_large` (422) when the whole entry is past the reply cap** (Huginn's daemon caps every reply at 65536 bytes; the cap, not the page budget, is what a read meets). Its `error` names the fork id and carries no order and no cursor, because there is nothing to continue with: `fork_too_large: fork alone is past the response budget` (vector `transport.readTooLarge`; the cap is in no vector). A stripped read serves a decider's `note` as `note: null`, never omitted (map-mode: every key is written); `desk_answer.note` is already nullable in the schema, so a served record validates as it stands. **Answer.** The body is the `answer_request` and nothing else a client may set: `fork_id`, `options_hash`, `answer`, `answered_at`, `request_id` (plus `retracted` and `retracts`, written `false` and nil unless the call retracts). The response is the stored outcome: `Counted`, `Revision`, `Retraction`, or `Refused` with its code, and the **`counted_answer_id`** the fork has once this call is applied (`n` or nil; derived as in "The counted answer is derived", the same value the `record` projection carries, so the desk needs no second read). A replay returns the stored outcome with the `counted_answer_id` current at the retry, not the one it had when first stored. **A `stale_fork` refusal carries the current card** in `answer_result.card`, so the desk can show the new fork without another read. Nothing is stored for a step 1 or 2 refusal, an unknown fork (or an unknown Mind, or a fork another Mind holds) or a `request_id_conflict`. `unavailable` promises nothing either way. -**Vectors (`transport` in `fork-card-vectors.json`).** `listRequest`, `readRequest` and `entry` (valid and invalid, with bytes); `listInvalid` (including `two_cards_share_one_order`, whose `refusal` is the exact text both judges must produce: `order 4 is shared by and `); `listScenarios` (six scenarios: over one Mind's five cards, paged open-only, recorded-only, all and `limit` 0; an empty Mind; and `order_zero_is_legal_but_no_cursor_names_it`, four cards of orders 3 to 0 paged by 3, whose first page ends at order 1 with `c1` and whose second page is the order-0 card with `next_cursor` nil; each response with its bytes, the cursor of page two being page one's `next_cursor`); `listShortPages` (three scenarios: a page that stops short and resumes below its last entry; `fork_too_large` on a first entry of order 3, continued with `c3`; and a short page ending at order 1 followed by `fork_too_large` on order 0, where the client is at the end; each step carries its bytes, and the receiver's budget is in no vector, so the judges check a short page as a nonempty strict prefix of the unbudgeted page and a refusal as naming the entry the page would open with); `doorFaults` (three cases: a door `request_id_conflict` is `unavailable` 503 on read and list and itself 409 on the answer); `authCases` (twenty-two cases: the MAS token, three `votes.*` tokens, a desk token with the other route's scope, a non-Active member on each route, and Huginn down on each route, with auth and membership judged first). Both suites replay the list requests through a reference judge and the auth cases through another. `mindRoutes` (a configured list of two Minds with their sockets and forks; 57 cases over the three routes: bad and percent-encoded segments, unknown Mind against unknown fork with `sameResponse` pairs of equal bytes, the POST on a Mind that does not hold the fork, scopes that are not per Mind, and auth judged before the Mind lookup) is replayed by both suites through a reference judge that reports the refusal, the socket it would open and the Mind that stored an answer. Top-level `unavailable` pins the 503 document. Scenarios pin: an `unavailable` where Huginn never had the request, retried into a `Counted` answer; an `unavailable` where Huginn had stored it, whose retry returns the stored outcome and adds no Revision; a replayed `Refused` with its card; `request_id_conflict`; the same id from another account; and a non-Active member with a bad body, with the door down, getting `forbidden` first. A bad body is refused before the door is asked. +**Vectors (`transport` in `fork-card-vectors.json`).** `listRequest`, `readRequest` and `entry` (valid and invalid, with bytes); `listInvalid` (including `two_cards_share_one_order`, whose `refusal` is the exact text both judges must produce: `order 4 is shared by and `); `listScenarios` (six scenarios: over one Mind's five cards, paged open-only, recorded-only, all and `limit` 0; an empty Mind; and `order_zero_is_legal_but_no_cursor_names_it`, four cards of orders 3 to 0 paged by 3, whose first page ends at order 1 with `c1` and whose second page is the order-0 card with `next_cursor` nil; each response with its bytes, the cursor of page two being page one's `next_cursor`); `listShortPages` (three scenarios: a page that stops short and resumes below its last entry; `fork_too_large` on a first entry of order 3, continued with `c3`; and a short page ending at order 1 followed by `fork_too_large` on order 0, where the client is at the end; each step carries its bytes, and the receiver's budget is in no vector, so the judges check a short page as a nonempty strict prefix of the unbudgeted page and a refusal as naming the entry the page would open with); `doorFaults` (three cases: a door `request_id_conflict` is `unavailable` 503 on read and list and itself 409 on the answer); `readTooLarge` (one case: a read whose entry is past the cap, refused `fork_too_large` 422 with the read-form text and its bytes); `authCases` (twenty-two cases: the MAS token, three `votes.*` tokens, a desk token with the other route's scope, a non-Active member on each route, and Huginn down on each route, with auth and membership judged first). Both suites replay the list requests through a reference judge and the auth cases through another. `mindRoutes` (a configured list of two Minds with their sockets and forks; 57 cases over the three routes: bad and percent-encoded segments, unknown Mind against unknown fork with `sameResponse` pairs of equal bytes, the POST on a Mind that does not hold the fork, scopes that are not per Mind, and auth judged before the Mind lookup) is replayed by both suites through a reference judge that reports the refusal, the socket it would open and the Mind that stored an answer. Top-level `unavailable` pins the 503 document. Scenarios pin: an `unavailable` where Huginn never had the request, retried into a `Counted` answer; an `unavailable` where Huginn had stored it, whose retry returns the stored outcome and adds no Revision; a replayed `Refused` with its card; `request_id_conflict`; the same id from another account; and a non-Active member with a bad body, with the door down, getting `forbidden` first. A bad body is refused before the door is asked. **Bifrost must still build:** all three routes; the `bifrost-desk` audience and the `desk.read` and `desk.answer` scopes on the OIDC token it issues to chat; the configured Mind list and its sockets (M1); the projection of cards and records from Huginn, the stamp, the cursor and the counts; the `DeskAnswer` call on the second door and the pass-through of its outcome; the decider list, sent with every answer, read and list request from the admin-set list only (M5); the `unavailable` answer. **Huginn must still build** the second door's wire, the per-call decider list it receives with each answer, read and list request, keeping no copy (M5), and the read side the projection uses (Eureka's cut spec, after her record PR). **The desk may build today** against the vectors: nothing in them needs a live service. @@ -193,3 +193,4 @@ Option B, **Bifrost receives the answer and Huginn stores it** (the design in th 9. The claims Bifrost verifies are PR #24's open item 5. (The audience `bifrost-desk`, the three routes and the scope names are ruled by Heimdall.) 10. The second door's wire between Bifrost and Huginn (pending Eureka's cut spec). `unavailable` is a document of its own, outside the refusal set, so a desk can retry it; calls 1 and 2 use it too when Huginn is down (vectors `huginn_down_*`). 11. While a known Mind's door is down its routes answer `unavailable` (503) and an unknown Mind answers `fork_unknown` (404), so the two are distinguishable for that period. M2 makes them identical only when every door is up; making them identical when one is down means answering `unavailable` for any `{mind}` not on the list, which tells a client nothing about which Minds exist but also hides a typo. Heimdall's to rule; the vectors do not pin it. +12. A fork's answers are unbounded in number, so a fork's entry can grow past the reply cap and become unreadable (`fork_too_large` on read, and on list when it is the first entry of a page). Whether the contract should cap the answers per fork is for Persona and Heimdall to rule; nothing here decides it. diff --git a/packages/cultnet-ts/test/governance-vectors.test.ts b/packages/cultnet-ts/test/governance-vectors.test.ts index c639ec73..6d4769bd 100644 --- a/packages/cultnet-ts/test/governance-vectors.test.ts +++ b/packages/cultnet-ts/test/governance-vectors.test.ts @@ -208,6 +208,7 @@ test("fork card: documents validate and encode to the vector bytes", () => { assert.ok(check(forkSchema, "refusal", v.message), v.name); assert.equal(hexOf(v.message), v.messagePackHex, v.name); } + assert.equal(fork.doorRefusals.find((r: { name: string }) => r.name === "fork_too_large").httpStatus, 422); }); test("fork card: every negative is refused by the schema or a stated rule", () => { @@ -531,7 +532,7 @@ test("fork transport: a page may stop short of limit, and a first entry past the assert.equal(r.code, "fork_too_large"); assert.equal(step.httpStatus, 422); assert.equal(step.fork_id, first.fork_id); assert.equal(step.order, first.order); - assert.equal(r.error, `fork_too_large: fork ${first.fork_id} (order ${first.order}) alone is past the response budget; read it by id`); + assert.equal(r.error, `fork_too_large: fork ${first.fork_id} (order ${first.order}) alone is past the response budget`); assert.ok(step.request.limit >= 1, "limit 0 never refuses"); if (first.order >= 1) { assert.ok(i + 1 < s.steps.length, s.name); last = `c${first.order}`; assert.ok(check(forkSchema, "cursor", last)); } else { assert.equal(i, s.steps.length - 1, `${s.name}: order 0 is the end`); assert.equal(check(forkSchema, "cursor", "c0"), false); last = undefined; } @@ -544,12 +545,29 @@ test("fork transport: a page may stop short of limit, and a first entry past the assert.deepEqual(covered, [3, 2, 1, 0, 2, 1, 0, 1]); }); +test("fork transport: a read whose entry is past the cap is fork_too_large (422), named by fork id alone", () => { + const doorStatus = fork.doorRefusals.find((r: any) => r.name === "fork_too_large").httpStatus; + assert.equal(fork.transport.readTooLarge.length, 1); + for (const c of fork.transport.readTooLarge) { + const held = c.entries.find((e: any) => e.card.fork_id === c.request.fork_id); + assert.ok(held !== undefined && check(forkSchema, "readRequest", c.request), c.name); + assert.ok(check(forkSchema, "refusal", c.refusal), c.name); + assert.equal(c.refusal.code, "fork_too_large"); + assert.equal(c.refusal.error, `fork_too_large: fork ${held.card.fork_id} alone is past the response budget`); + assert.ok(!c.refusal.error.includes("order"), "a read names no order"); + assert.equal(hexOf(c.refusal), c.refusalHex, c.name); + assert.equal(c.httpStatus, 422); assert.equal(c.httpStatus, doorStatus); + } +}); + test("fork transport: a door request_id_conflict is a fault on read and list (503 unavailable) and a refusal only on the answer", () => { assert.deepEqual(fork.transport.doorFaults.map((c: any) => c.route), ["read", "list", "answer"]); for (const c of fork.transport.doorFaults) { - const got = c.route === "answer" ? c.doorCode : c.doorCode === "request_id_conflict" ? "unavailable" : c.doorCode; - assert.equal(c.clientCode, got, c.name); - assert.equal(c.httpStatus, got === "unavailable" ? fork.unavailable[0].httpStatus : fork.doorRefusals.find((r: any) => r.name === got).httpStatus, c.name); + const source = c.route === "answer" ? fork.doorRefusals.find((r: any) => r.name === c.doorCode) : fork.unavailable[0]; + assert.equal(c.responseHex, source.messagePackHex, c.name); + assert.equal(hexOf(c.response), c.responseHex, c.name); + assert.equal(c.httpStatus, source.httpStatus, c.name); + assert.ok(check(forkSchema, c.route === "answer" ? "refusal" : "unavailable", c.response), c.name); } }); diff --git a/scripts/gen-governance-vectors.py b/scripts/gen-governance-vectors.py index ecb6b313..914cfcee 100644 --- a/scripts/gen-governance-vectors.py +++ b/scripts/gen-governance-vectors.py @@ -425,7 +425,11 @@ LISTS = [ # A page may stop short of `limit` when the next entry would pass the receiver's response budget; a list whose first # entry alone passes it is refused `fork_too_large` (422). The budget is not in the vector: a step records what the receiver answered, and the # judges check it against the unbudgeted page (a nonempty strict prefix, resumed below its last entry) or against the entry the page would open with. -def too_large_doc(c): return {"schemaVersion": "gamecult.fork.refusal.v0", "code": "fork_too_large", "error": f"fork_too_large: fork {c['fork_id']} (order {c['order']}) alone is past the response budget; read it by id"} +def too_large_doc(c): return {"schemaVersion": "gamecult.fork.refusal.v0", "code": "fork_too_large", "error": f"fork_too_large: fork {c['fork_id']} (order {c['order']}) alone is past the response budget"} +def read_too_large_doc(c): return {"schemaVersion": "gamecult.fork.refusal.v0", "code": "fork_too_large", "error": f"fork_too_large: fork {c['fork_id']} alone is past the response budget"} +READ_TOO_LARGE = [{"name": "a_read_whose_entry_is_past_the_cap_is_fork_too_large_without_an_order", "ordinal": 9, "entries": ORDER0, + "request": {"schemaVersion": "gamecult.fork.read_request.v0", "mind": "gamecult-chat", "fork_id": ORDER0[0]["card"]["fork_id"]}, + "refusal": read_too_large_doc(ORDER0[0]["card"]), "refusalHex": hx(read_too_large_doc(ORDER0[0]["card"])), "httpStatus": HTTP["fork_too_large"], "fork_id": ORDER0[0]["card"]["fork_id"]}] def short_step(entries, ordinal, rq, keep): full = judge_list(entries, ordinal, rq); page = full["forks"][:keep] assert 0 < keep < len(full["forks"]) @@ -637,8 +641,10 @@ fork = { messagePackHex=hx(DUP_LIST), refusal=order_shared(DUP_LIST["forks"])[0])], "listScenarios": [{"name": l["name"], "ordinal": l["ordinal"], "entries": l["entries"], "steps": l["steps"]} for l in LISTS], "listShortPages": SHORT_PAGES, - "doorFaults": [{"name": f"{r}_door_answers_request_id_conflict_the_client_sees_unavailable", "route": r, "doorCode": "request_id_conflict", "clientCode": "unavailable", "httpStatus": 503} for r in ("read", "list")] - + [{"name": "answer_door_answers_request_id_conflict_the_client_sees_it", "route": "answer", "doorCode": "request_id_conflict", "clientCode": "request_id_conflict", "httpStatus": HTTP["request_id_conflict"]}], + "readTooLarge": READ_TOO_LARGE, + "doorFaults": [dict(c, response=r, responseHex=hx(r)) for c, r in + [({"name": f"{x}_door_answers_request_id_conflict_the_client_sees_unavailable", "route": x, "doorCode": "request_id_conflict", "httpStatus": 503}, unavailable_doc(30)) for x in ("read", "list")] + + [({"name": "answer_door_answers_request_id_conflict_the_client_sees_it", "route": "answer", "doorCode": "request_id_conflict", "httpStatus": HTTP["request_id_conflict"]}, refusal_doc("request_id_conflict"))]], "mindRoutes": {"minds": MINDS, "forks": MIND_FORKS, "cases": MINDCASES, "sameResponse": SAME_RESPONSE}, "authCases": [{"name": a[0], "route": a[1], "token": a[2], "expect": a[3], "huginn_down": len(a) > 4} for a in AUTH], }, diff --git a/tests/GameCult.Networking.Tests/GovernanceVectorTests.cs b/tests/GameCult.Networking.Tests/GovernanceVectorTests.cs index df674a75..58ce7041 100644 --- a/tests/GameCult.Networking.Tests/GovernanceVectorTests.cs +++ b/tests/GameCult.Networking.Tests/GovernanceVectorTests.cs @@ -303,6 +303,7 @@ namespace GameCult.Networking.Tests Assert.That(Errors(ForkSchema, "refusal", v!["message"]), Is.Empty, S(v["name"])); Assert.That(HexOf(v["message"]), Is.EqualTo(S(v["messagePackHex"])), S(v["name"])); } + Assert.That(Fork["doorRefusals"]!.AsArray().Single(r => S(r!["name"]) == "fork_too_large")!["httpStatus"]!.GetValue(), Is.EqualTo(422)); } [Test] @@ -729,7 +730,7 @@ namespace GameCult.Networking.Tests Assert.That(HexOf(r), Is.EqualTo(S(step["refusalHex"])), name); Assert.That(S(r["code"]), Is.EqualTo("fork_too_large")); Assert.That(step["httpStatus"]!.GetValue(), Is.EqualTo(422)); Assert.That(S(step["fork_id"]), Is.EqualTo(S(first["fork_id"]))); Assert.That(step["order"]!.GetValue(), Is.EqualTo(first["order"]!.GetValue())); - Assert.That(S(r["error"]), Is.EqualTo($"fork_too_large: fork {S(first["fork_id"])} (order {first["order"]!.GetValue()}) alone is past the response budget; read it by id")); + Assert.That(S(r["error"]), Is.EqualTo($"fork_too_large: fork {S(first["fork_id"])} (order {first["order"]!.GetValue()}) alone is past the response budget")); Assert.That(step["request"]!["limit"]!.GetValue(), Is.GreaterThanOrEqualTo(1)); var order = first["order"]!.GetValue(); if (order >= 1) { Assert.That(i + 1, Is.LessThan(steps.Count), name); last = $"c{order}"; follow = true; Assert.That(Errors(ForkSchema, "cursor", JsonValue.Create(last)), Is.Empty); } @@ -741,6 +742,25 @@ namespace GameCult.Networking.Tests Assert.That(sc[0]!["steps"]!.AsArray().Select(x => x!["response"]!["forks"]!.AsArray().Count), Is.EqualTo(new[] { 2, 2 })); } + [Test] + public void ForkTransport_AReadWhoseEntryIsPastTheCap_IsForkTooLarge422_NamedByForkIdAlone() + { + var doorStatus = Fork["doorRefusals"]!.AsArray().Single(r => S(r!["name"]) == "fork_too_large")!["httpStatus"]!.GetValue(); + var cases = Fork["transport"]!["readTooLarge"]!.AsArray(); + Assert.That(cases.Count, Is.EqualTo(1)); + foreach (var c in cases) + { + var held = c!["entries"]!.AsArray().Single(e => S(e!["card"]!["fork_id"]) == S(c["request"]!["fork_id"]))!; + Assert.That(Errors(ForkSchema, "readRequest", c["request"]), Is.Empty, S(c["name"])); + Assert.That(Errors(ForkSchema, "refusal", c["refusal"]), Is.Empty, S(c["name"])); + Assert.That(S(c["refusal"]!["code"]), Is.EqualTo("fork_too_large")); + Assert.That(S(c["refusal"]!["error"]), Is.EqualTo($"fork_too_large: fork {S(held["card"]!["fork_id"])} alone is past the response budget")); + Assert.That(S(c["refusal"]!["error"]), Does.Not.Contain("order"), "a read names no order"); + Assert.That(HexOf(c["refusal"]), Is.EqualTo(S(c["refusalHex"])), S(c["name"])); + Assert.That(c["httpStatus"]!.GetValue(), Is.EqualTo(422)); Assert.That(c["httpStatus"]!.GetValue(), Is.EqualTo(doorStatus)); + } + } + [Test] public void ForkTransport_ADoorRequestIdConflict_IsAFaultOnReadAndList_AndARefusalOnlyOnTheAnswer() { @@ -748,10 +768,11 @@ namespace GameCult.Networking.Tests Assert.That(cases.Select(c => S(c!["route"])), Is.EqualTo(new[] { "read", "list", "answer" })); foreach (var c in cases) { - var got = S(c!["route"]) == "answer" ? S(c["doorCode"]) : "unavailable"; - Assert.That(S(c["clientCode"]), Is.EqualTo(got), S(c["name"])); - var status = got == "unavailable" ? 503 : Fork["doorRefusals"]!.AsArray().Single(r => S(r!["name"]) == got)!["httpStatus"]!.GetValue(); - Assert.That(c["httpStatus"]!.GetValue(), Is.EqualTo(status), S(c["name"])); + var source = S(c!["route"]) == "answer" ? Fork["doorRefusals"]!.AsArray().Single(r => S(r!["name"]) == S(c["doorCode"]))! : Fork["unavailable"]![0]!; + Assert.That(S(c["responseHex"]), Is.EqualTo(S(source["messagePackHex"])), S(c["name"])); + Assert.That(HexOf(c["response"]), Is.EqualTo(S(c["responseHex"])), S(c["name"])); + Assert.That(c["httpStatus"]!.GetValue(), Is.EqualTo(source["httpStatus"]!.GetValue()), S(c["name"])); + Assert.That(Errors(ForkSchema, S(c["route"]) == "answer" ? "refusal" : "unavailable", c["response"]), Is.Empty, S(c["name"])); } } From d07a07e8578106f3c6eb0dcbc61bee290e322c60 Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 01:29:24 +0000 Subject: [PATCH 04/93] fork-card.md: say the read form of fork_too_large is a target; Huginn answers ResponseTooLarge today Co-Authored-By: Claude Sonnet 5.5 --- contracts/cultnet/governance/fork-card.md | 1 + 1 file changed, 1 insertion(+) diff --git a/contracts/cultnet/governance/fork-card.md b/contracts/cultnet/governance/fork-card.md index 0c1574d9..f581f16f 100644 --- a/contracts/cultnet/governance/fork-card.md +++ b/contracts/cultnet/governance/fork-card.md @@ -194,3 +194,4 @@ Option B, **Bifrost receives the answer and Huginn stores it** (the design in th 10. The second door's wire between Bifrost and Huginn (pending Eureka's cut spec). `unavailable` is a document of its own, outside the refusal set, so a desk can retry it; calls 1 and 2 use it too when Huginn is down (vectors `huginn_down_*`). 11. While a known Mind's door is down its routes answer `unavailable` (503) and an unknown Mind answers `fork_unknown` (404), so the two are distinguishable for that period. M2 makes them identical only when every door is up; making them identical when one is down means answering `unavailable` for any `{mind}` not on the list, which tells a client nothing about which Minds exist but also hides a typo. Heimdall's to rule; the vectors do not pin it. 12. A fork's answers are unbounded in number, so a fork's entry can grow past the reply cap and become unreadable (`fork_too_large` on read, and on list when it is the first entry of a page). Whether the contract should cap the answers per fork is for Persona and Heimdall to rule; nothing here decides it. +13. The read form of `fork_too_large` is a target, not current behaviour: Huginn's door answers an over-cap read as `Refused(ResponseTooLarge{bytes, limit})`, which carries no fork id and is not `fork_too_large`. Bifrost maps it to `fork_too_large` (422) using the route's `fork_id`; until it does, a read past the cap reaches the client as 503 `unavailable`. From f6c9bf5f975177d2033578e9b51b4d21930b728a Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 01:29:35 +0000 Subject: [PATCH 05/93] Fork card: fork_too_large (422) for a list whose first entry, or a read whose entry, is past the reply cap (#88) --- .../cultnet/governance/fork-card-vectors.json | 1181 +++++++++++++++++ contracts/cultnet/governance/fork-card.md | 12 +- .../cultnet/governance/fork-card.schema.json | 1 + .../test/governance-vectors.test.ts | 67 + scripts/gen-governance-vectors.py | 31 +- .../GovernanceVectorTests.cs | 84 ++ 6 files changed, 1369 insertions(+), 7 deletions(-) diff --git a/contracts/cultnet/governance/fork-card-vectors.json b/contracts/cultnet/governance/fork-card-vectors.json index 7c6f5df7..5f5e7c22 100644 --- a/contracts/cultnet/governance/fork-card-vectors.json +++ b/contracts/cultnet/governance/fork-card-vectors.json @@ -2692,6 +2692,16 @@ "error": "request_invalid: the request does not satisfy its document" }, "messagePackHex": "83AD736368656D6156657273696F6EB867616D6563756C742E666F726B2E7265667573616C2E7630A4636F6465AF726571756573745F696E76616C6964A56572726F72D93A726571756573745F696E76616C69643A20746865207265717565737420646F6573206E6F7420736174697366792069747320646F63756D656E74" + }, + { + "name": "fork_too_large", + "httpStatus": 422, + "message": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "fork_too_large", + "error": "fork_too_large: fork gamecult-chat:question:q3 (order 3) alone is past the response budget" + }, + "messagePackHex": "83AD736368656D6156657273696F6EB867616D6563756C742E666F726B2E7265667573616C2E7630A4636F6465AE666F726B5F746F6F5F6C61726765A56572726F72D95A666F726B5F746F6F5F6C617267653A20666F726B2067616D6563756C742D636861743A7175657374696F6E3A713320286F7264657220332920616C6F6E6520697320706173742074686520726573706F6E736520627564676574" } ], "unavailable": [ @@ -6561,6 +6571,1177 @@ ] } ], + "listShortPages": [ + { + "name": "a_short_page_resumes_below_its_last_entry", + "ordinal": 9, + "entries": [ + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q3", + "title": "Fork 3", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q3", + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", + "counted_answer_id": null, + "answers": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q2", + "title": "Fork 2", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 2, + "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q2", + "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79", + "counted_answer_id": null, + "answers": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q1", + "title": "Fork 1", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 1, + "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q1", + "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a", + "counted_answer_id": null, + "answers": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q0", + "title": "Fork 0", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 0, + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q0", + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d", + "counted_answer_id": null, + "answers": [] + } + } + ], + "steps": [ + { + "request": { + "schemaVersion": "gamecult.fork.list_request.v0", + "mind": "gamecult-chat", + "status": null, + "cursor": null, + "limit": 3 + }, + "response": { + "schemaVersion": "gamecult.fork.list.v0", + "mind": "gamecult-chat", + "stamp": "s9", + "open_count": 4, + "recorded_count": 0, + "forks": [ + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q3", + "title": "Fork 3", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q3", + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", + "counted_answer_id": null, + "answers": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q2", + "title": "Fork 2", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 2, + "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q2", + "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79", + "counted_answer_id": null, + "answers": [] + } + } + ], + "next_cursor": "c2" + }, + "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A27339AA6F70656E5F636F756E7404AE7265636F726465645F636F756E7400A5666F726B739283AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A57469746C65A6466F726B2033A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657203AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532B1636F756E7465645F616E737765725F6964C0A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132A57469746C65A6466F726B2032A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657202AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72A26332" + }, + { + "request": { + "schemaVersion": "gamecult.fork.list_request.v0", + "mind": "gamecult-chat", + "status": null, + "cursor": "c2", + "limit": 3 + }, + "response": { + "schemaVersion": "gamecult.fork.list.v0", + "mind": "gamecult-chat", + "stamp": "s9", + "open_count": 4, + "recorded_count": 0, + "forks": [ + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q1", + "title": "Fork 1", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 1, + "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q1", + "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a", + "counted_answer_id": null, + "answers": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q0", + "title": "Fork 0", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 0, + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q0", + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d", + "counted_answer_id": null, + "answers": [] + } + } + ], + "next_cursor": null + }, + "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A27339AA6F70656E5F636F756E7404AE7265636F726465645F636F756E7400A5666F726B739283AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131A57469746C65A6466F726B2031A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657201AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361B1636F756E7465645F616E737765725F6964C0A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7130A57469746C65A6466F726B2030A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657200AC6F7074696F6E735F68617368D94031373737626430366666616664663665383162656435393739663337313933633462346537633364323530346639303465393133313031326630373637613964A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7130AC6F7074696F6E735F68617368D94031373737626430366666616664663665383162656435393739663337313933633462346537633364323530346639303465393133313031326630373637613964B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72C0" + } + ] + }, + { + "name": "a_first_entry_past_the_budget_is_fork_too_large_and_the_client_continues_below_its_order", + "ordinal": 9, + "entries": [ + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q3", + "title": "Fork 3", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q3", + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", + "counted_answer_id": null, + "answers": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q2", + "title": "Fork 2", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 2, + "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q2", + "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79", + "counted_answer_id": null, + "answers": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q1", + "title": "Fork 1", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 1, + "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q1", + "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a", + "counted_answer_id": null, + "answers": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q0", + "title": "Fork 0", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 0, + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q0", + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d", + "counted_answer_id": null, + "answers": [] + } + } + ], + "steps": [ + { + "request": { + "schemaVersion": "gamecult.fork.list_request.v0", + "mind": "gamecult-chat", + "status": null, + "cursor": null, + "limit": 3 + }, + "refusal": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "fork_too_large", + "error": "fork_too_large: fork gamecult-chat:question:q3 (order 3) alone is past the response budget" + }, + "refusalHex": "83AD736368656D6156657273696F6EB867616D6563756C742E666F726B2E7265667573616C2E7630A4636F6465AE666F726B5F746F6F5F6C61726765A56572726F72D95A666F726B5F746F6F5F6C617267653A20666F726B2067616D6563756C742D636861743A7175657374696F6E3A713320286F7264657220332920616C6F6E6520697320706173742074686520726573706F6E736520627564676574", + "httpStatus": 422, + "fork_id": "gamecult-chat:question:q3", + "order": 3 + }, + { + "request": { + "schemaVersion": "gamecult.fork.list_request.v0", + "mind": "gamecult-chat", + "status": null, + "cursor": "c3", + "limit": 3 + }, + "response": { + "schemaVersion": "gamecult.fork.list.v0", + "mind": "gamecult-chat", + "stamp": "s9", + "open_count": 4, + "recorded_count": 0, + "forks": [ + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q2", + "title": "Fork 2", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 2, + "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q2", + "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79", + "counted_answer_id": null, + "answers": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q1", + "title": "Fork 1", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 1, + "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q1", + "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a", + "counted_answer_id": null, + "answers": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q0", + "title": "Fork 0", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 0, + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q0", + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d", + "counted_answer_id": null, + "answers": [] + } + } + ], + "next_cursor": null + }, + "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A27339AA6F70656E5F636F756E7404AE7265636F726465645F636F756E7400A5666F726B739383AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132A57469746C65A6466F726B2032A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657202AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739B1636F756E7465645F616E737765725F6964C0A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131A57469746C65A6466F726B2031A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657201AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361B1636F756E7465645F616E737765725F6964C0A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7130A57469746C65A6466F726B2030A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657200AC6F7074696F6E735F68617368D94031373737626430366666616664663665383162656435393739663337313933633462346537633364323530346639303465393133313031326630373637613964A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7130AC6F7074696F6E735F68617368D94031373737626430366666616664663665383162656435393739663337313933633462346537633364323530346639303465393133313031326630373637613964B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72C0" + } + ] + }, + { + "name": "fork_too_large_on_order_zero_leaves_nothing_below", + "ordinal": 9, + "entries": [ + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q1", + "title": "Fork 1", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 1, + "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q1", + "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a", + "counted_answer_id": null, + "answers": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q0", + "title": "Fork 0", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 0, + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q0", + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d", + "counted_answer_id": null, + "answers": [] + } + } + ], + "steps": [ + { + "request": { + "schemaVersion": "gamecult.fork.list_request.v0", + "mind": "gamecult-chat", + "status": null, + "cursor": null, + "limit": 5 + }, + "response": { + "schemaVersion": "gamecult.fork.list.v0", + "mind": "gamecult-chat", + "stamp": "s9", + "open_count": 2, + "recorded_count": 0, + "forks": [ + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q1", + "title": "Fork 1", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 1, + "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q1", + "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a", + "counted_answer_id": null, + "answers": [] + } + } + ], + "next_cursor": "c1" + }, + "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A27339AA6F70656E5F636F756E7402AE7265636F726465645F636F756E7400A5666F726B739183AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131A57469746C65A6466F726B2031A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657201AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72A26331" + }, + { + "request": { + "schemaVersion": "gamecult.fork.list_request.v0", + "mind": "gamecult-chat", + "status": null, + "cursor": "c1", + "limit": 5 + }, + "refusal": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "fork_too_large", + "error": "fork_too_large: fork gamecult-chat:question:q0 (order 0) alone is past the response budget" + }, + "refusalHex": "83AD736368656D6156657273696F6EB867616D6563756C742E666F726B2E7265667573616C2E7630A4636F6465AE666F726B5F746F6F5F6C61726765A56572726F72D95A666F726B5F746F6F5F6C617267653A20666F726B2067616D6563756C742D636861743A7175657374696F6E3A713020286F7264657220302920616C6F6E6520697320706173742074686520726573706F6E736520627564676574", + "httpStatus": 422, + "fork_id": "gamecult-chat:question:q0", + "order": 0 + } + ] + } + ], + "readTooLarge": [ + { + "name": "a_read_whose_entry_is_past_the_cap_is_fork_too_large_without_an_order", + "ordinal": 9, + "entries": [ + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q3", + "title": "Fork 3", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q3", + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", + "counted_answer_id": null, + "answers": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q2", + "title": "Fork 2", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 2, + "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q2", + "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79", + "counted_answer_id": null, + "answers": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q1", + "title": "Fork 1", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 1, + "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q1", + "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a", + "counted_answer_id": null, + "answers": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q0", + "title": "Fork 0", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 0, + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q0", + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d", + "counted_answer_id": null, + "answers": [] + } + } + ], + "request": { + "schemaVersion": "gamecult.fork.read_request.v0", + "mind": "gamecult-chat", + "fork_id": "gamecult-chat:question:q3" + }, + "refusal": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "fork_too_large", + "error": "fork_too_large: fork gamecult-chat:question:q3 alone is past the response budget" + }, + "refusalHex": "83AD736368656D6156657273696F6EB867616D6563756C742E666F726B2E7265667573616C2E7630A4636F6465AE666F726B5F746F6F5F6C61726765A56572726F72D950666F726B5F746F6F5F6C617267653A20666F726B2067616D6563756C742D636861743A7175657374696F6E3A713320616C6F6E6520697320706173742074686520726573706F6E736520627564676574", + "httpStatus": 422, + "fork_id": "gamecult-chat:question:q3" + } + ], + "doorFaults": [ + { + "name": "read_door_answers_request_id_conflict_the_client_sees_unavailable", + "route": "read", + "doorCode": "request_id_conflict", + "httpStatus": 503, + "response": { + "schemaVersion": "gamecult.fork.unavailable.v0", + "code": "unavailable", + "error": "unavailable: the outcome is not confirmed, try again with the same request_id", + "retryAfterSeconds": 30 + }, + "responseHex": "84AD736368656D6156657273696F6EBC67616D6563756C742E666F726B2E756E617661696C61626C652E7630A4636F6465AB756E617661696C61626C65A56572726F72D94D756E617661696C61626C653A20746865206F7574636F6D65206973206E6F7420636F6E6669726D65642C2074727920616761696E2077697468207468652073616D6520726571756573745F6964B1726574727941667465725365636F6E64731E" + }, + { + "name": "list_door_answers_request_id_conflict_the_client_sees_unavailable", + "route": "list", + "doorCode": "request_id_conflict", + "httpStatus": 503, + "response": { + "schemaVersion": "gamecult.fork.unavailable.v0", + "code": "unavailable", + "error": "unavailable: the outcome is not confirmed, try again with the same request_id", + "retryAfterSeconds": 30 + }, + "responseHex": "84AD736368656D6156657273696F6EBC67616D6563756C742E666F726B2E756E617661696C61626C652E7630A4636F6465AB756E617661696C61626C65A56572726F72D94D756E617661696C61626C653A20746865206F7574636F6D65206973206E6F7420636F6E6669726D65642C2074727920616761696E2077697468207468652073616D6520726571756573745F6964B1726574727941667465725365636F6E64731E" + }, + { + "name": "answer_door_answers_request_id_conflict_the_client_sees_it", + "route": "answer", + "doorCode": "request_id_conflict", + "httpStatus": 409, + "response": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "request_id_conflict", + "error": "request_id_conflict: this request_id was used for a different body" + }, + "responseHex": "83AD736368656D6156657273696F6EB867616D6563756C742E666F726B2E7265667573616C2E7630A4636F6465B3726571756573745F69645F636F6E666C696374A56572726F72D942726571756573745F69645F636F6E666C6963743A207468697320726571756573745F696420776173207573656420666F72206120646966666572656E7420626F6479" + } + ], "mindRoutes": { "minds": { "gamecult-chat": "huginn-gamecult-chat.sock", diff --git a/contracts/cultnet/governance/fork-card.md b/contracts/cultnet/governance/fork-card.md index 51357a89..f581f16f 100644 --- a/contracts/cultnet/governance/fork-card.md +++ b/contracts/cultnet/governance/fork-card.md @@ -105,7 +105,7 @@ Source: the desk's client needs (Ratatoskr PR #24 section 12, `ratatoskr-desk-sp | call | route (proposal) | request | response | token | |---|---|---|---|---| | 1 list | `GET /api/desk/{mind}/forks?status=&cursor=&limit=` | `list_request`: `mind` from the path, the rest from the query string | `list` | audience `bifrost-desk`, scope `desk.read` | -| 2 read | `GET /api/desk/{mind}/forks/{fork_id}` (the id percent-encoded) | `read_request`, its `mind` and `fork_id` from the path | `entry`, or `refusal` `fork_unknown` (404) | `desk.read` | +| 2 read | `GET /api/desk/{mind}/forks/{fork_id}` (the id percent-encoded) | `read_request`, its `mind` and `fork_id` from the path | `entry`, or `refusal` `fork_unknown` (404) or `fork_too_large` (422) | `desk.read` | | 3 answer | `POST /api/desk/{mind}/answers` | `answer_request` as the body; `fork_id` stays in the body, the Mind is the route's and is no body field | `answer_result` (200 for `Counted`, `Revision` and `Retraction`; a stored `Refused` answer keeps the `answer_result` body and takes its code's status from the counting-rules table: `stale_fork` 409, `not_a_decider` 403, `fork_closed` 409, `choice_invalid` 400, `bad_retraction` 400, `already_retracted` 409, as the vectors' `results` pin; a replay returns the same status as the first call; the desk reads `outcome` and `code` from the body), or a `refusal`, or `unavailable` | audience `bifrost-desk`, scope `desk.answer` | **The `{mind}` segment** (Heimdall's rulings M1 to M5; vectors `transport.mindRoutes`). @@ -118,15 +118,15 @@ Source: the desk's client needs (Ratatoskr PR #24 section 12, `ratatoskr-desk-sp Calls 1 and 2 are enough for a read-only desk. Every response is the pinned MessagePack document or its JSON form with the same keys (open choice 1 of `vote-api.md`: the media type is unsettled). -**Auth, in order, per route.** (1) A bearer, else `unauthenticated` (401). (2) The token's audience is `bifrost-desk`: **the Matrix Authentication Service token chat holds today is refused, and so is any `votes.*` token** (audience `bifrost-votes`), both as `unauthenticated`. (3) The scope the route needs, else `insufficient_scope` (403): `desk.read` for calls 1 and 2, `desk.answer` for call 3, and neither implies the other. (4) The account behind `sub` is an Active member, else `forbidden` (403), as in the vote API; scope is judged first. (5) For call 3 the body is then shaped (steps 1 and 2 above), so **a non-Active member who sends a bad body, including one naming `answerer` or `authority`, gets `forbidden`, not a body refusal**; a body naming either key at any depth is `body_forbidden_field` only for an Active member. (6) Only then is Huginn asked, so `unavailable` never hides an auth or body refusal. A `votes.*` token touches none of the three. Heimdall's file leaves the audience and the claims check open; `insufficient_scope`, `forbidden`, `request_invalid` (400, a list or read request that fails its document) and `request_id_conflict` are added to the door refusals. +**Auth, in order, per route.** (1) A bearer, else `unauthenticated` (401). (2) The token's audience is `bifrost-desk`: **the Matrix Authentication Service token chat holds today is refused, and so is any `votes.*` token** (audience `bifrost-votes`), both as `unauthenticated`. (3) The scope the route needs, else `insufficient_scope` (403): `desk.read` for calls 1 and 2, `desk.answer` for call 3, and neither implies the other. (4) The account behind `sub` is an Active member, else `forbidden` (403), as in the vote API; scope is judged first. (5) For call 3 the body is then shaped (steps 1 and 2 above), so **a non-Active member who sends a bad body, including one naming `answerer` or `authority`, gets `forbidden`, not a body refusal**; a body naming either key at any depth is `body_forbidden_field` only for an Active member. (6) Only then is Huginn asked, so `unavailable` never hides an auth or body refusal. A `votes.*` token touches none of the three. Heimdall's file leaves the audience and the claims check open; `insufficient_scope`, `forbidden`, `request_invalid` (400, a list or read request that fails its document; an unknown key, `authority` included, is `request_invalid` here, whatever Huginn's own decode does with it) and `request_id_conflict` are added to the door refusals, and so is `fork_too_large` (422, list and read; see List and Read). **`request_id_conflict` is answer-only**: the idempotency key exists only on the answer. A door `request_id_conflict` on a read or list is a door fault and reaches the client as 503 `unavailable`, never as `request_id_conflict` (Persona, 2026-11-11, agreed with Heimdall; vectors `transport.doorFaults`, each carrying the client response's bytes). The `unavailable` text's "same request_id" retry hint is for answers; reads and lists carry no request_id.. -**List.** `list_request`: `{mind, status, cursor, limit}`. `mind` is the route's `{mind}`, a Mind id from the configured list. `status` is `open`, `recorded` or nil for both; `recorded` means a closed card. Cards come **newest first by `order`**. `order` is the card's unique rank per Mind: an integer from 0 that never changes once the card exists, later questions only appending (Huginn derives it from the Mind's questions sorted by admission ordinal, then key). A Mind never holds two cards of one `order`; a list or card set that does is refused, and the refusal names both fork ids. 0 is legal, but no cursor names it (`c0` fails the cursor pattern): a page ending at order 1 returns `c1`, and the next page returns the order-0 card alone with `next_cursor` nil. `limit` is 0 to 50; **`limit` 0 returns no entries, only the counts and the stamp**, which is what "N open" on the Mind's header needs. `cursor` is nil or `c`: the cards whose `order` is below N; a client treats it as opaque and sends back the `next_cursor` it was given. `list`: `{mind, stamp, open_count, recorded_count, forks[entry], next_cursor}`. The counts cover every card the viewer may see, whatever `status` and `cursor` asked for. `stamp` is `s`, N the receiver's last-seen Huginn admission ordinal for the Mind's desk documents: the same stamp means the same state, so a desk polls `limit` 0 and fetches bodies only when the stamp moves. `next_cursor` is nil on the last page. An `entry` is `{card, record}`: the card and its `record` (the answers with their outcomes and the derived `counted_answer_id`). +**List.** `list_request`: `{mind, status, cursor, limit}`. `mind` is the route's `{mind}`, a Mind id from the configured list. `status` is `open`, `recorded` or nil for both; `recorded` means a closed card. Cards come **newest first by `order`**. `order` is the card's unique rank per Mind: an integer from 0 that never changes once the card exists, later questions only appending (Huginn derives it from the Mind's questions sorted by admission ordinal, then key). A Mind never holds two cards of one `order`; a list or card set that does is refused, and the refusal names both fork ids. 0 is legal, but no cursor names it (`c0` fails the cursor pattern): a page ending at order 1 returns `c1`, and the next page returns the order-0 card alone with `next_cursor` nil. `limit` is 0 to 50; **`limit` 0 returns no entries, only the counts and the stamp**, which is what "N open" on the Mind's header needs. `cursor` is nil or `c`: the cards whose `order` is below N; a client treats it as opaque and sends back the `next_cursor` it was given. `list`: `{mind, stamp, open_count, recorded_count, forks[entry], next_cursor}`. The counts cover every card the viewer may see, whatever `status` and `cursor` asked for. `stamp` is `s`, N the receiver's last-seen Huginn admission ordinal for the Mind's desk documents: the same stamp means the same state, so a desk polls `limit` 0 and fetches bodies only when the stamp moves. `next_cursor` is nil on the last page. **A page may hold fewer than `limit` entries** when the next entry would push the encoded response past the receiver's response budget (Huginn: 65536 bytes); it then holds at least one, `next_cursor` is `c` so the next page resumes below it, and the counts and the stamp are those of the full list, unchanged. **`fork_too_large`** (HTTP 422) refuses a page whose **first** entry alone is past the budget: 422 because the request is well formed and authorised but names a resource the list cannot carry, and 413 is for a request body that is too large, which this is not; retrying returns the same refusal. Its `error` names the fork id and its `order` (`fork_too_large: fork (order ) alone is past the response budget`). The client continues the list with cursor `c`, the cards strictly below that order, which skips this fork; it is then readable only by id (the read route returns the whole entry, and refuses with the same code only when that entry alone is past the cap, see Read); `c` is valid only when n is at least 1, so for order 0 there is nothing below and the client has reached the end. `limit` 0 returns no entries and never refuses. (Ruled by Persona, 2026-11-11.) An `entry` is `{card, record}`: the card and its `record` (the answers with their outcomes and the derived `counted_answer_id`). -**Read.** `read_request` is `{mind, fork_id}`; the response is the `entry` for one fork of that Mind. Its `card.options_hash` is **computed by the server** from the five shown members as in "Options hash" and the client recomputes it from what it rendered; a card whose hash is not the hash of its members is no card (negative vector `card_hash_wrong`). The record's `fork_id` and `options_hash` are the card's, and `counted_answer_id` is derived, not copied (negative vectors for each). Every `desk.read` holder sees every fork's card and outcome in a configured Mind; only a decider's notes depend on the caller, going to callers in the admin-set `deciders` list, which Bifrost enforces at the door (ruling `desk-read-visibility`, option (c)); `fork_unknown` is only for a fork id that does not exist under the route's Mind (an unknown Mind answers the same, M2), so no fork is ever hidden by it, and a caller without `desk.read` is refused by the auth order above (`insufficient_scope`) before any lookup. **Bifrost applies visibility at the door; Huginn returns the whole record to the door.** Every `desk.read` holder sees cards and outcomes; notes (a decider's own words) only go to callers in the admin-set `deciders` list. The read and list shapes are unchanged and carry no audience field (`eureka-substrate:question:desk-read-visibility`, Heimdall's option (c)). A stripped read serves a decider's `note` as `note: null`, never omitted (map-mode: every key is written); `desk_answer.note` is already nullable in the schema, so a served record validates as it stands. +**Read.** `read_request` is `{mind, fork_id}`; the response is the `entry` for one fork of that Mind. Its `card.options_hash` is **computed by the server** from the five shown members as in "Options hash" and the client recomputes it from what it rendered; a card whose hash is not the hash of its members is no card (negative vector `card_hash_wrong`). The record's `fork_id` and `options_hash` are the card's, and `counted_answer_id` is derived, not copied (negative vectors for each). Every `desk.read` holder sees every fork's card and outcome in a configured Mind; only a decider's notes depend on the caller, going to callers in the admin-set `deciders` list, which Bifrost enforces at the door (ruling `desk-read-visibility`, option (c)); `fork_unknown` is only for a fork id that does not exist under the route's Mind (an unknown Mind answers the same, M2), so no fork is ever hidden by it, and a caller without `desk.read` is refused by the auth order above (`insufficient_scope`) before any lookup. **Bifrost applies visibility at the door; Huginn returns the whole record to the door.** Every `desk.read` holder sees cards and outcomes; notes (a decider's own words) only go to callers in the admin-set `deciders` list. The read and list shapes are unchanged and carry no audience field (`eureka-substrate:question:desk-read-visibility`, Heimdall's option (c)). **A read is refused `fork_too_large` (422) when the whole entry is past the reply cap** (Huginn's daemon caps every reply at 65536 bytes; the cap, not the page budget, is what a read meets). Its `error` names the fork id and carries no order and no cursor, because there is nothing to continue with: `fork_too_large: fork alone is past the response budget` (vector `transport.readTooLarge`; the cap is in no vector). A stripped read serves a decider's `note` as `note: null`, never omitted (map-mode: every key is written); `desk_answer.note` is already nullable in the schema, so a served record validates as it stands. **Answer.** The body is the `answer_request` and nothing else a client may set: `fork_id`, `options_hash`, `answer`, `answered_at`, `request_id` (plus `retracted` and `retracts`, written `false` and nil unless the call retracts). The response is the stored outcome: `Counted`, `Revision`, `Retraction`, or `Refused` with its code, and the **`counted_answer_id`** the fork has once this call is applied (`n` or nil; derived as in "The counted answer is derived", the same value the `record` projection carries, so the desk needs no second read). A replay returns the stored outcome with the `counted_answer_id` current at the retry, not the one it had when first stored. **A `stale_fork` refusal carries the current card** in `answer_result.card`, so the desk can show the new fork without another read. Nothing is stored for a step 1 or 2 refusal, an unknown fork (or an unknown Mind, or a fork another Mind holds) or a `request_id_conflict`. `unavailable` promises nothing either way. -**Vectors (`transport` in `fork-card-vectors.json`).** `listRequest`, `readRequest` and `entry` (valid and invalid, with bytes); `listInvalid` (including `two_cards_share_one_order`, whose `refusal` is the exact text both judges must produce: `order 4 is shared by and `); `listScenarios` (six scenarios: over one Mind's five cards, paged open-only, recorded-only, all and `limit` 0; an empty Mind; and `order_zero_is_legal_but_no_cursor_names_it`, four cards of orders 3 to 0 paged by 3, whose first page ends at order 1 with `c1` and whose second page is the order-0 card with `next_cursor` nil; each response with its bytes, the cursor of page two being page one's `next_cursor`); `authCases` (twenty-two cases: the MAS token, three `votes.*` tokens, a desk token with the other route's scope, a non-Active member on each route, and Huginn down on each route, with auth and membership judged first). Both suites replay the list requests through a reference judge and the auth cases through another. `mindRoutes` (a configured list of two Minds with their sockets and forks; 57 cases over the three routes: bad and percent-encoded segments, unknown Mind against unknown fork with `sameResponse` pairs of equal bytes, the POST on a Mind that does not hold the fork, scopes that are not per Mind, and auth judged before the Mind lookup) is replayed by both suites through a reference judge that reports the refusal, the socket it would open and the Mind that stored an answer. Top-level `unavailable` pins the 503 document. Scenarios pin: an `unavailable` where Huginn never had the request, retried into a `Counted` answer; an `unavailable` where Huginn had stored it, whose retry returns the stored outcome and adds no Revision; a replayed `Refused` with its card; `request_id_conflict`; the same id from another account; and a non-Active member with a bad body, with the door down, getting `forbidden` first. A bad body is refused before the door is asked. +**Vectors (`transport` in `fork-card-vectors.json`).** `listRequest`, `readRequest` and `entry` (valid and invalid, with bytes); `listInvalid` (including `two_cards_share_one_order`, whose `refusal` is the exact text both judges must produce: `order 4 is shared by and `); `listScenarios` (six scenarios: over one Mind's five cards, paged open-only, recorded-only, all and `limit` 0; an empty Mind; and `order_zero_is_legal_but_no_cursor_names_it`, four cards of orders 3 to 0 paged by 3, whose first page ends at order 1 with `c1` and whose second page is the order-0 card with `next_cursor` nil; each response with its bytes, the cursor of page two being page one's `next_cursor`); `listShortPages` (three scenarios: a page that stops short and resumes below its last entry; `fork_too_large` on a first entry of order 3, continued with `c3`; and a short page ending at order 1 followed by `fork_too_large` on order 0, where the client is at the end; each step carries its bytes, and the receiver's budget is in no vector, so the judges check a short page as a nonempty strict prefix of the unbudgeted page and a refusal as naming the entry the page would open with); `doorFaults` (three cases: a door `request_id_conflict` is `unavailable` 503 on read and list and itself 409 on the answer); `readTooLarge` (one case: a read whose entry is past the cap, refused `fork_too_large` 422 with the read-form text and its bytes); `authCases` (twenty-two cases: the MAS token, three `votes.*` tokens, a desk token with the other route's scope, a non-Active member on each route, and Huginn down on each route, with auth and membership judged first). Both suites replay the list requests through a reference judge and the auth cases through another. `mindRoutes` (a configured list of two Minds with their sockets and forks; 57 cases over the three routes: bad and percent-encoded segments, unknown Mind against unknown fork with `sameResponse` pairs of equal bytes, the POST on a Mind that does not hold the fork, scopes that are not per Mind, and auth judged before the Mind lookup) is replayed by both suites through a reference judge that reports the refusal, the socket it would open and the Mind that stored an answer. Top-level `unavailable` pins the 503 document. Scenarios pin: an `unavailable` where Huginn never had the request, retried into a `Counted` answer; an `unavailable` where Huginn had stored it, whose retry returns the stored outcome and adds no Revision; a replayed `Refused` with its card; `request_id_conflict`; the same id from another account; and a non-Active member with a bad body, with the door down, getting `forbidden` first. A bad body is refused before the door is asked. **Bifrost must still build:** all three routes; the `bifrost-desk` audience and the `desk.read` and `desk.answer` scopes on the OIDC token it issues to chat; the configured Mind list and its sockets (M1); the projection of cards and records from Huginn, the stamp, the cursor and the counts; the `DeskAnswer` call on the second door and the pass-through of its outcome; the decider list, sent with every answer, read and list request from the admin-set list only (M5); the `unavailable` answer. **Huginn must still build** the second door's wire, the per-call decider list it receives with each answer, read and list request, keeping no copy (M5), and the read side the projection uses (Eureka's cut spec, after her record PR). **The desk may build today** against the vectors: nothing in them needs a live service. @@ -193,3 +193,5 @@ Option B, **Bifrost receives the answer and Huginn stores it** (the design in th 9. The claims Bifrost verifies are PR #24's open item 5. (The audience `bifrost-desk`, the three routes and the scope names are ruled by Heimdall.) 10. The second door's wire between Bifrost and Huginn (pending Eureka's cut spec). `unavailable` is a document of its own, outside the refusal set, so a desk can retry it; calls 1 and 2 use it too when Huginn is down (vectors `huginn_down_*`). 11. While a known Mind's door is down its routes answer `unavailable` (503) and an unknown Mind answers `fork_unknown` (404), so the two are distinguishable for that period. M2 makes them identical only when every door is up; making them identical when one is down means answering `unavailable` for any `{mind}` not on the list, which tells a client nothing about which Minds exist but also hides a typo. Heimdall's to rule; the vectors do not pin it. +12. A fork's answers are unbounded in number, so a fork's entry can grow past the reply cap and become unreadable (`fork_too_large` on read, and on list when it is the first entry of a page). Whether the contract should cap the answers per fork is for Persona and Heimdall to rule; nothing here decides it. +13. The read form of `fork_too_large` is a target, not current behaviour: Huginn's door answers an over-cap read as `Refused(ResponseTooLarge{bytes, limit})`, which carries no fork id and is not `fork_too_large`. Bifrost maps it to `fork_too_large` (422) using the route's `fork_id`; until it does, a read past the cap reaches the client as 503 `unavailable`. diff --git a/contracts/cultnet/governance/fork-card.schema.json b/contracts/cultnet/governance/fork-card.schema.json index 62acc30a..bd4c21d8 100644 --- a/contracts/cultnet/governance/fork-card.schema.json +++ b/contracts/cultnet/governance/fork-card.schema.json @@ -714,6 +714,7 @@ "body_unknown_field", "body_invalid", "fork_unknown", + "fork_too_large", "insufficient_scope", "forbidden", "request_invalid", diff --git a/packages/cultnet-ts/test/governance-vectors.test.ts b/packages/cultnet-ts/test/governance-vectors.test.ts index 38e41b4d..6d4769bd 100644 --- a/packages/cultnet-ts/test/governance-vectors.test.ts +++ b/packages/cultnet-ts/test/governance-vectors.test.ts @@ -208,6 +208,7 @@ test("fork card: documents validate and encode to the vector bytes", () => { assert.ok(check(forkSchema, "refusal", v.message), v.name); assert.equal(hexOf(v.message), v.messagePackHex, v.name); } + assert.equal(fork.doorRefusals.find((r: { name: string }) => r.name === "fork_too_large").httpStatus, 422); }); test("fork card: every negative is refused by the schema or a stated rule", () => { @@ -504,6 +505,72 @@ test("fork transport: order 0 is legal, no cursor names it, and the page ending assert.equal(check(forkSchema, "cursor", "c0"), false); }); +test("fork transport: a page may stop short of limit, and a first entry past the budget is fork_too_large (422) continued below its order", () => { + const sc = fork.transport.listShortPages; + assert.deepEqual(sc.map((x: any) => x.name), ["a_short_page_resumes_below_its_last_entry", "a_first_entry_past_the_budget_is_fork_too_large_and_the_client_continues_below_its_order", "fork_too_large_on_order_zero_leaves_nothing_below"]); + assert.ok((forkSchema.$defs.refusal.properties.code as { enum: string[] }).enum.includes("fork_too_large")); + const covered: number[] = []; + for (const s of sc) { + let last: string | null | undefined = undefined; + s.steps.forEach((step: any, i: number) => { + if (last !== undefined) assert.equal(step.request.cursor, last, `${s.name}: the step follows the cursor or the refused order`); + const full = judgeList(s.entries, s.ordinal, step.request); + if (step.response !== undefined) { + const r = step.response as any, n = r.forks.length; + assert.ok(check(forkSchema, "list", r), s.name); + assert.equal(hexOf(r), step.responseHex, s.name); + assert.deepEqual(r.forks, full.forks.slice(0, n), `${s.name}: a prefix of the unbudgeted page`); + assert.ok(n >= 1, s.name); + assert.equal(r.next_cursor, full.next_cursor !== null || n < full.forks.length ? `c${r.forks[n - 1].card.order}` : null, s.name); + assert.equal(r.stamp, full.stamp); assert.equal(r.open_count, full.open_count); assert.equal(r.recorded_count, full.recorded_count); + covered.push(...r.forks.map((f: any) => f.card.order)); + last = r.next_cursor; + } else { + const r = step.refusal, first = full.forks[0].card as any; + assert.ok(check(forkSchema, "refusal", r), s.name); + assert.equal(hexOf(r), step.refusalHex, s.name); + assert.equal(r.code, "fork_too_large"); + assert.equal(step.httpStatus, 422); + assert.equal(step.fork_id, first.fork_id); assert.equal(step.order, first.order); + assert.equal(r.error, `fork_too_large: fork ${first.fork_id} (order ${first.order}) alone is past the response budget`); + assert.ok(step.request.limit >= 1, "limit 0 never refuses"); + if (first.order >= 1) { assert.ok(i + 1 < s.steps.length, s.name); last = `c${first.order}`; assert.ok(check(forkSchema, "cursor", last)); } + else { assert.equal(i, s.steps.length - 1, `${s.name}: order 0 is the end`); assert.equal(check(forkSchema, "cursor", "c0"), false); last = undefined; } + } + }); + } + assert.deepEqual(sc[0].steps.map((x: any) => x.response.next_cursor), ["c2", null]); + assert.deepEqual(sc[0].steps.map((x: any) => x.response.forks.length), [2, 2]); + assert.deepEqual([sc[1].steps[0].order, sc[2].steps[1].order], [3, 0]); + assert.deepEqual(covered, [3, 2, 1, 0, 2, 1, 0, 1]); +}); + +test("fork transport: a read whose entry is past the cap is fork_too_large (422), named by fork id alone", () => { + const doorStatus = fork.doorRefusals.find((r: any) => r.name === "fork_too_large").httpStatus; + assert.equal(fork.transport.readTooLarge.length, 1); + for (const c of fork.transport.readTooLarge) { + const held = c.entries.find((e: any) => e.card.fork_id === c.request.fork_id); + assert.ok(held !== undefined && check(forkSchema, "readRequest", c.request), c.name); + assert.ok(check(forkSchema, "refusal", c.refusal), c.name); + assert.equal(c.refusal.code, "fork_too_large"); + assert.equal(c.refusal.error, `fork_too_large: fork ${held.card.fork_id} alone is past the response budget`); + assert.ok(!c.refusal.error.includes("order"), "a read names no order"); + assert.equal(hexOf(c.refusal), c.refusalHex, c.name); + assert.equal(c.httpStatus, 422); assert.equal(c.httpStatus, doorStatus); + } +}); + +test("fork transport: a door request_id_conflict is a fault on read and list (503 unavailable) and a refusal only on the answer", () => { + assert.deepEqual(fork.transport.doorFaults.map((c: any) => c.route), ["read", "list", "answer"]); + for (const c of fork.transport.doorFaults) { + const source = c.route === "answer" ? fork.doorRefusals.find((r: any) => r.name === c.doorCode) : fork.unavailable[0]; + assert.equal(c.responseHex, source.messagePackHex, c.name); + assert.equal(hexOf(c.response), c.responseHex, c.name); + assert.equal(c.httpStatus, source.httpStatus, c.name); + assert.ok(check(forkSchema, c.route === "answer" ? "refusal" : "unavailable", c.response), c.name); + } +}); + test("fork transport: auth is bearer, then audience, then scope, and a votes token touches nothing", () => { const codes = (forkSchema.$defs.refusal.properties.code as { enum: string[] }).enum; const seen = new Set(); diff --git a/scripts/gen-governance-vectors.py b/scripts/gen-governance-vectors.py index b6b931d1..914cfcee 100644 --- a/scripts/gen-governance-vectors.py +++ b/scripts/gen-governance-vectors.py @@ -358,7 +358,7 @@ scen += [ sc("R1_a_target_less_retraction_with_retracted_flipped_is_body_invalid_before_any_request_id_lookup", CARD, [X1, rf(IL_B, sr("n2"), "bad_retraction"), door(dict(IL_B, retracted=False), sr("n9"), "body_invalid")], "n1"), ] REASON_CODES = ["stale_fork", "not_a_decider", "fork_closed", "choice_invalid", "bad_retraction", "already_retracted"] -HTTP = {"unauthenticated": 401, "body_forbidden_field": 400, "body_unknown_field": 400, "body_invalid": 400, "fork_unknown": 404, "insufficient_scope": 403, "forbidden": 403, "request_id_conflict": 409, "request_invalid": 400, +HTTP = {"unauthenticated": 401, "body_forbidden_field": 400, "body_unknown_field": 400, "body_invalid": 400, "fork_unknown": 404, "insufficient_scope": 403, "forbidden": 403, "request_id_conflict": 409, "request_invalid": 400, "fork_too_large": 422, "stale_fork": 409, "not_a_decider": 403, "fork_closed": 409, "choice_invalid": 400, "bad_retraction": 400, "already_retracted": 409} STORED_REFUSALS = [] for reason in REASON_CODES: @@ -422,6 +422,28 @@ LISTS = [ {"name": "order_zero_is_legal_but_no_cursor_names_it", "ordinal": 9, "entries": ORDER0, "steps": [lstep(ORDER0, 9, lreq(limit=3)), lstep(ORDER0, 9, lreq(cursor="c1", limit=3))]}, {"name": "a_mind_with_no_forks", "ordinal": 7, "entries": [], "steps": [lstep([], 7, lreq(mind="nibu", limit=10))]}, ] +# A page may stop short of `limit` when the next entry would pass the receiver's response budget; a list whose first +# entry alone passes it is refused `fork_too_large` (422). The budget is not in the vector: a step records what the receiver answered, and the +# judges check it against the unbudgeted page (a nonempty strict prefix, resumed below its last entry) or against the entry the page would open with. +def too_large_doc(c): return {"schemaVersion": "gamecult.fork.refusal.v0", "code": "fork_too_large", "error": f"fork_too_large: fork {c['fork_id']} (order {c['order']}) alone is past the response budget"} +def read_too_large_doc(c): return {"schemaVersion": "gamecult.fork.refusal.v0", "code": "fork_too_large", "error": f"fork_too_large: fork {c['fork_id']} alone is past the response budget"} +READ_TOO_LARGE = [{"name": "a_read_whose_entry_is_past_the_cap_is_fork_too_large_without_an_order", "ordinal": 9, "entries": ORDER0, + "request": {"schemaVersion": "gamecult.fork.read_request.v0", "mind": "gamecult-chat", "fork_id": ORDER0[0]["card"]["fork_id"]}, + "refusal": read_too_large_doc(ORDER0[0]["card"]), "refusalHex": hx(read_too_large_doc(ORDER0[0]["card"])), "httpStatus": HTTP["fork_too_large"], "fork_id": ORDER0[0]["card"]["fork_id"]}] +def short_step(entries, ordinal, rq, keep): + full = judge_list(entries, ordinal, rq); page = full["forks"][:keep] + assert 0 < keep < len(full["forks"]) + r = dict(full, forks=page, next_cursor=f"c{page[-1]['card']['order']}"); return {"request": rq, "response": r, "responseHex": hx(r)} +def large_step(entries, ordinal, rq): + c = judge_list(entries, ordinal, rq)["forks"][0]["card"]; r = too_large_doc(c) + return {"request": rq, "refusal": r, "refusalHex": hx(r), "httpStatus": HTTP["fork_too_large"], "fork_id": c["fork_id"], "order": c["order"]} +SHORT_PAGES = [ + {"name": "a_short_page_resumes_below_its_last_entry", "ordinal": 9, "entries": ORDER0, "steps": [short_step(ORDER0, 9, lreq(limit=3), 2), lstep(ORDER0, 9, lreq(cursor="c2", limit=3))]}, + {"name": "a_first_entry_past_the_budget_is_fork_too_large_and_the_client_continues_below_its_order", "ordinal": 9, "entries": ORDER0, + "steps": [large_step(ORDER0, 9, lreq(limit=3)), lstep(ORDER0, 9, lreq(cursor="c3", limit=3))]}, + {"name": "fork_too_large_on_order_zero_leaves_nothing_below", "ordinal": 9, "entries": ORDER0[2:], + "steps": [short_step(ORDER0[2:], 9, lreq(limit=5), 1), large_step(ORDER0[2:], 9, lreq(cursor="c1", limit=5))]}, +] DUP_LIST = judge_list(ENTRIES + [fentry(q4b)], 412, lreq(limit=50)) assert order_shared(DUP_LIST["forks"]) == ["order 4 is shared by gamecult-chat:question:q4 and gamecult-chat:question:q4b"] TOK = lambda aud, *sc, active=True: {"audience": aud, "scopes": list(sc), "active": active} @@ -586,7 +608,7 @@ fork = { ("no_answerer", "answerer is stamped on every stored answer", dict(CNT, answerer="")), ]]}, "results": [{"name": r["name"], "httpStatus": r["httpStatus"], "message": r["message"], "messagePackHex": hx(r["message"])} for r in STORED_REFUSALS], - "doorRefusals": [{"name": c, "httpStatus": HTTP[c], "message": refusal_doc(c), "messagePackHex": hx(refusal_doc(c))} for c in TAIL], + "doorRefusals": [{"name": c, "httpStatus": HTTP[c], "message": refusal_doc(c), "messagePackHex": hx(refusal_doc(c))} for c in TAIL] + [{"name": "fork_too_large", "httpStatus": HTTP["fork_too_large"], "message": SHORT_PAGES[1]["steps"][0]["refusal"], "messagePackHex": SHORT_PAGES[1]["steps"][0]["refusalHex"]}], "unavailable": [{"name": "second_door_down", "httpStatus": 503, "message": unavailable_doc(30), "messagePackHex": hx(unavailable_doc(30))}, {"name": "second_door_down_no_hint", "httpStatus": 503, "message": unavailable_doc(None), "messagePackHex": hx(unavailable_doc(None))}], "rulingCites": cites, @@ -618,6 +640,11 @@ fork = { + [dict(bad("two_cards_share_one_order", "rule: order is a unique rank per Mind; the refusal names both fork ids (not expressible in the schema)", DUP_LIST), messagePackHex=hx(DUP_LIST), refusal=order_shared(DUP_LIST["forks"])[0])], "listScenarios": [{"name": l["name"], "ordinal": l["ordinal"], "entries": l["entries"], "steps": l["steps"]} for l in LISTS], + "listShortPages": SHORT_PAGES, + "readTooLarge": READ_TOO_LARGE, + "doorFaults": [dict(c, response=r, responseHex=hx(r)) for c, r in + [({"name": f"{x}_door_answers_request_id_conflict_the_client_sees_unavailable", "route": x, "doorCode": "request_id_conflict", "httpStatus": 503}, unavailable_doc(30)) for x in ("read", "list")] + + [({"name": "answer_door_answers_request_id_conflict_the_client_sees_it", "route": "answer", "doorCode": "request_id_conflict", "httpStatus": HTTP["request_id_conflict"]}, refusal_doc("request_id_conflict"))]], "mindRoutes": {"minds": MINDS, "forks": MIND_FORKS, "cases": MINDCASES, "sameResponse": SAME_RESPONSE}, "authCases": [{"name": a[0], "route": a[1], "token": a[2], "expect": a[3], "huginn_down": len(a) > 4} for a in AUTH], }, diff --git a/tests/GameCult.Networking.Tests/GovernanceVectorTests.cs b/tests/GameCult.Networking.Tests/GovernanceVectorTests.cs index d34e6fe6..58ce7041 100644 --- a/tests/GameCult.Networking.Tests/GovernanceVectorTests.cs +++ b/tests/GameCult.Networking.Tests/GovernanceVectorTests.cs @@ -303,6 +303,7 @@ namespace GameCult.Networking.Tests Assert.That(Errors(ForkSchema, "refusal", v!["message"]), Is.Empty, S(v["name"])); Assert.That(HexOf(v["message"]), Is.EqualTo(S(v["messagePackHex"])), S(v["name"])); } + Assert.That(Fork["doorRefusals"]!.AsArray().Single(r => S(r!["name"]) == "fork_too_large")!["httpStatus"]!.GetValue(), Is.EqualTo(422)); } [Test] @@ -692,6 +693,89 @@ namespace GameCult.Networking.Tests Assert.That(Errors(ForkSchema, "cursor", JsonValue.Create("c0")), Is.Not.Empty); } + [Test] + public void ForkTransport_AShortPage_AndForkTooLarge422_ContinuedBelowItsOrder() + { + var sc = Fork["transport"]!["listShortPages"]!.AsArray(); + Assert.That(sc.Select(x => S(x!["name"])), Is.EqualTo(new[] { "a_short_page_resumes_below_its_last_entry", "a_first_entry_past_the_budget_is_fork_too_large_and_the_client_continues_below_its_order", "fork_too_large_on_order_zero_leaves_nothing_below" })); + var schema = JsonNode.Parse(File.ReadAllText(Path.Combine(RepoRoot(), "contracts", "cultnet", ForkSchema)))!; + Assert.That(schema["$defs"]!["refusal"]!["properties"]!["code"]!["enum"]!.AsArray().Select(S), Does.Contain("fork_too_large")); + var covered = new List(); + foreach (var s in sc) + { + var entries = s!["entries"]!.AsArray(); var steps = s["steps"]!.AsArray(); string? last = null; var follow = false; + for (var i = 0; i < steps.Count; i++) + { + var step = steps[i]!; var name = S(s["name"]); + if (follow) Assert.That(step["request"]!["cursor"] is null ? null : S(step["request"]!["cursor"]), Is.EqualTo(last), name); + var full = JudgeList(entries, s["ordinal"]!.GetValue(), step["request"]!); + var fullForks = full["forks"]!.AsArray(); + if (step["response"] is not null) + { + var r = step["response"]!; var forks = r["forks"]!.AsArray(); var n = forks.Count; + Assert.That(Errors(ForkSchema, "list", r), Is.Empty, name); + Assert.That(HexOf(r), Is.EqualTo(S(step["responseHex"])), name); + Assert.That(n, Is.GreaterThanOrEqualTo(1), name); + for (var k = 0; k < n; k++) Assert.That(JsonNode.DeepEquals(forks[k], fullForks[k]), Is.True, $"{name}: a prefix of the unbudgeted page"); + var o = forks[n - 1]!["card"]!["order"]!.GetValue(); + if (full["next_cursor"] is not null || n < fullForks.Count) Assert.That(S(r["next_cursor"]), Is.EqualTo($"c{o}"), name); else Assert.That(r["next_cursor"], Is.Null, name); + Assert.That(S(r["stamp"]), Is.EqualTo(S(full["stamp"]))); Assert.That(r["open_count"]!.GetValue(), Is.EqualTo(full["open_count"]!.GetValue())); Assert.That(r["recorded_count"]!.GetValue(), Is.EqualTo(full["recorded_count"]!.GetValue())); + covered.AddRange(forks.Select(f => f!["card"]!["order"]!.GetValue())); + last = r["next_cursor"] is null ? null : S(r["next_cursor"]); follow = true; + } + else + { + var r = step["refusal"]!; var first = fullForks[0]!["card"]!; + Assert.That(Errors(ForkSchema, "refusal", r), Is.Empty, name); + Assert.That(HexOf(r), Is.EqualTo(S(step["refusalHex"])), name); + Assert.That(S(r["code"]), Is.EqualTo("fork_too_large")); Assert.That(step["httpStatus"]!.GetValue(), Is.EqualTo(422)); + Assert.That(S(step["fork_id"]), Is.EqualTo(S(first["fork_id"]))); Assert.That(step["order"]!.GetValue(), Is.EqualTo(first["order"]!.GetValue())); + Assert.That(S(r["error"]), Is.EqualTo($"fork_too_large: fork {S(first["fork_id"])} (order {first["order"]!.GetValue()}) alone is past the response budget")); + Assert.That(step["request"]!["limit"]!.GetValue(), Is.GreaterThanOrEqualTo(1)); + var order = first["order"]!.GetValue(); + if (order >= 1) { Assert.That(i + 1, Is.LessThan(steps.Count), name); last = $"c{order}"; follow = true; Assert.That(Errors(ForkSchema, "cursor", JsonValue.Create(last)), Is.Empty); } + else { Assert.That(i, Is.EqualTo(steps.Count - 1), $"{name}: order 0 is the end"); follow = false; } + } + } + } + Assert.That(covered, Is.EqualTo(new long[] { 3, 2, 1, 0, 2, 1, 0, 1 })); + Assert.That(sc[0]!["steps"]!.AsArray().Select(x => x!["response"]!["forks"]!.AsArray().Count), Is.EqualTo(new[] { 2, 2 })); + } + + [Test] + public void ForkTransport_AReadWhoseEntryIsPastTheCap_IsForkTooLarge422_NamedByForkIdAlone() + { + var doorStatus = Fork["doorRefusals"]!.AsArray().Single(r => S(r!["name"]) == "fork_too_large")!["httpStatus"]!.GetValue(); + var cases = Fork["transport"]!["readTooLarge"]!.AsArray(); + Assert.That(cases.Count, Is.EqualTo(1)); + foreach (var c in cases) + { + var held = c!["entries"]!.AsArray().Single(e => S(e!["card"]!["fork_id"]) == S(c["request"]!["fork_id"]))!; + Assert.That(Errors(ForkSchema, "readRequest", c["request"]), Is.Empty, S(c["name"])); + Assert.That(Errors(ForkSchema, "refusal", c["refusal"]), Is.Empty, S(c["name"])); + Assert.That(S(c["refusal"]!["code"]), Is.EqualTo("fork_too_large")); + Assert.That(S(c["refusal"]!["error"]), Is.EqualTo($"fork_too_large: fork {S(held["card"]!["fork_id"])} alone is past the response budget")); + Assert.That(S(c["refusal"]!["error"]), Does.Not.Contain("order"), "a read names no order"); + Assert.That(HexOf(c["refusal"]), Is.EqualTo(S(c["refusalHex"])), S(c["name"])); + Assert.That(c["httpStatus"]!.GetValue(), Is.EqualTo(422)); Assert.That(c["httpStatus"]!.GetValue(), Is.EqualTo(doorStatus)); + } + } + + [Test] + public void ForkTransport_ADoorRequestIdConflict_IsAFaultOnReadAndList_AndARefusalOnlyOnTheAnswer() + { + var cases = Fork["transport"]!["doorFaults"]!.AsArray(); + Assert.That(cases.Select(c => S(c!["route"])), Is.EqualTo(new[] { "read", "list", "answer" })); + foreach (var c in cases) + { + var source = S(c!["route"]) == "answer" ? Fork["doorRefusals"]!.AsArray().Single(r => S(r!["name"]) == S(c["doorCode"]))! : Fork["unavailable"]![0]!; + Assert.That(S(c["responseHex"]), Is.EqualTo(S(source["messagePackHex"])), S(c["name"])); + Assert.That(HexOf(c["response"]), Is.EqualTo(S(c["responseHex"])), S(c["name"])); + Assert.That(c["httpStatus"]!.GetValue(), Is.EqualTo(source["httpStatus"]!.GetValue()), S(c["name"])); + Assert.That(Errors(ForkSchema, S(c["route"]) == "answer" ? "refusal" : "unavailable", c["response"]), Is.Empty, S(c["name"])); + } + } + [Test] public void ForkTransport_Auth_BearerThenAudienceThenScope_AVotesTokenTouchesNothing() { From de8a062ab9d8c510c8b36e634fae8f108a570aff Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 02:10:37 +0000 Subject: [PATCH 06/93] Fork card: stale answer over an oversize re-read is Refused(stale_fork), never counted; refusal text is informational (#89) --- .../cultnet/governance/fork-card-vectors.json | 207 ++++++++++++++++++ contracts/cultnet/governance/fork-card.md | 12 +- .../test/governance-vectors.test.ts | 22 +- scripts/gen-governance-vectors.py | 6 + .../GovernanceVectorTests.cs | 28 ++- 5 files changed, 263 insertions(+), 12 deletions(-) diff --git a/contracts/cultnet/governance/fork-card-vectors.json b/contracts/cultnet/governance/fork-card-vectors.json index 5f5e7c22..38b255dc 100644 --- a/contracts/cultnet/governance/fork-card-vectors.json +++ b/contracts/cultnet/governance/fork-card-vectors.json @@ -7702,6 +7702,213 @@ "fork_id": "gamecult-chat:question:q3" } ], + "answerTooLarge": [ + { + "name": "a_stale_answer_whose_card_reread_is_past_the_cap_is_fork_too_large_over_a_stored_stale_refusal", + "entries": [ + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q3", + "title": "Fork 3", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q3", + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", + "counted_answer_id": null, + "answers": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q2", + "title": "Fork 2", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 2, + "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q2", + "options_hash": "5c7c2945e8e8689c6c928b11d7e0a033640b0c66afb7fcffb9e740c019d63e79", + "counted_answer_id": null, + "answers": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q1", + "title": "Fork 1", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 1, + "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q1", + "options_hash": "61959964bd214ea1b961554c43ec46a9faf156dd547cc50c8c16bbdcd55f223a", + "counted_answer_id": null, + "answers": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q0", + "title": "Fork 0", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 0, + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q0", + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d", + "counted_answer_id": null, + "answers": [] + } + } + ], + "request": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:q3", + "options_hash": "14b8ffbdb61106dbc06f69f3fc8e2072af7a205d766e4cc040e89733013d54dc", + "answer": { + "choice": "yes", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "0000a001-0000-4000-8000-00000000a001" + }, + "refusal": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "fork_too_large", + "error": "fork_too_large: fork gamecult-chat:question:q3 alone is past the response budget" + }, + "refusalHex": "83AD736368656D6156657273696F6EB867616D6563756C742E666F726B2E7265667573616C2E7630A4636F6465AE666F726B5F746F6F5F6C61726765A56572726F72D950666F726B5F746F6F5F6C617267653A20666F726B2067616D6563756C742D636861743A7175657374696F6E3A713320616C6F6E6520697320706173742074686520726573706F6E736520627564676574", + "httpStatus": 422, + "outcome": "Refused", + "code": "stale_fork", + "counted": false, + "fork_id": "gamecult-chat:question:q3" + } + ], "doorFaults": [ { "name": "read_door_answers_request_id_conflict_the_client_sees_unavailable", diff --git a/contracts/cultnet/governance/fork-card.md b/contracts/cultnet/governance/fork-card.md index f581f16f..73ae333c 100644 --- a/contracts/cultnet/governance/fork-card.md +++ b/contracts/cultnet/governance/fork-card.md @@ -15,7 +15,7 @@ All documents are CultNet map-mode: every key written, nil where absent, closed | `gamecult.fork.answer_request.v0` | the answer a decider sends | the client; Bifrost forwards it unchanged to Huginn's second door | | `gamecult.desk_answer.v0` | the stored answer with one tagged outcome | Huginn only | | `gamecult.fork.answer_result.v0` | what the sender gets back for a stored answer | Bifrost, from the stored `desk_answer` | -| `gamecult.fork.refusal.v0` | a body refused at the door; nothing stored | Bifrost | +| `gamecult.fork.refusal.v0` | a body refused at the door, nothing stored (but `fork_too_large` on a stale answer, which sits over a stored `Refused(stale_fork)`) | Bifrost | | `gamecult.fork.record.v0` | a fork's answers plus the derived counted answer | a projection of the stored `desk_answer`s | | `gamecult.fork.ruling_cite.v0` | the citation a ruling carries | the Mind admitting the ruling | | `gamecult.fork.list_request.v0` | list a Mind's forks (Transport) | the desk | @@ -106,7 +106,7 @@ Source: the desk's client needs (Ratatoskr PR #24 section 12, `ratatoskr-desk-sp |---|---|---|---|---| | 1 list | `GET /api/desk/{mind}/forks?status=&cursor=&limit=` | `list_request`: `mind` from the path, the rest from the query string | `list` | audience `bifrost-desk`, scope `desk.read` | | 2 read | `GET /api/desk/{mind}/forks/{fork_id}` (the id percent-encoded) | `read_request`, its `mind` and `fork_id` from the path | `entry`, or `refusal` `fork_unknown` (404) or `fork_too_large` (422) | `desk.read` | -| 3 answer | `POST /api/desk/{mind}/answers` | `answer_request` as the body; `fork_id` stays in the body, the Mind is the route's and is no body field | `answer_result` (200 for `Counted`, `Revision` and `Retraction`; a stored `Refused` answer keeps the `answer_result` body and takes its code's status from the counting-rules table: `stale_fork` 409, `not_a_decider` 403, `fork_closed` 409, `choice_invalid` 400, `bad_retraction` 400, `already_retracted` 409, as the vectors' `results` pin; a replay returns the same status as the first call; the desk reads `outcome` and `code` from the body), or a `refusal`, or `unavailable` | audience `bifrost-desk`, scope `desk.answer` | +| 3 answer | `POST /api/desk/{mind}/answers` | `answer_request` as the body; `fork_id` stays in the body, the Mind is the route's and is no body field | `answer_result` (200 for `Counted`, `Revision` and `Retraction`; a stored `Refused` answer keeps the `answer_result` body and takes its code's status from the counting-rules table: `stale_fork` 409, `not_a_decider` 403, `fork_closed` 409, `choice_invalid` 400, `bad_retraction` 400, `already_retracted` 409, as the vectors' `results` pin; a replay returns the same status as the first call; the desk reads `outcome` and `code` from the body), or a `refusal` (`fork_unknown`, or `fork_too_large` when a stale answer's card re-read is past the cap), or `unavailable` | audience `bifrost-desk`, scope `desk.answer` | **The `{mind}` segment** (Heimdall's rulings M1 to M5; vectors `transport.mindRoutes`). @@ -120,13 +120,13 @@ Calls 1 and 2 are enough for a read-only desk. Every response is the pinned Mess **Auth, in order, per route.** (1) A bearer, else `unauthenticated` (401). (2) The token's audience is `bifrost-desk`: **the Matrix Authentication Service token chat holds today is refused, and so is any `votes.*` token** (audience `bifrost-votes`), both as `unauthenticated`. (3) The scope the route needs, else `insufficient_scope` (403): `desk.read` for calls 1 and 2, `desk.answer` for call 3, and neither implies the other. (4) The account behind `sub` is an Active member, else `forbidden` (403), as in the vote API; scope is judged first. (5) For call 3 the body is then shaped (steps 1 and 2 above), so **a non-Active member who sends a bad body, including one naming `answerer` or `authority`, gets `forbidden`, not a body refusal**; a body naming either key at any depth is `body_forbidden_field` only for an Active member. (6) Only then is Huginn asked, so `unavailable` never hides an auth or body refusal. A `votes.*` token touches none of the three. Heimdall's file leaves the audience and the claims check open; `insufficient_scope`, `forbidden`, `request_invalid` (400, a list or read request that fails its document; an unknown key, `authority` included, is `request_invalid` here, whatever Huginn's own decode does with it) and `request_id_conflict` are added to the door refusals, and so is `fork_too_large` (422, list and read; see List and Read). **`request_id_conflict` is answer-only**: the idempotency key exists only on the answer. A door `request_id_conflict` on a read or list is a door fault and reaches the client as 503 `unavailable`, never as `request_id_conflict` (Persona, 2026-11-11, agreed with Heimdall; vectors `transport.doorFaults`, each carrying the client response's bytes). The `unavailable` text's "same request_id" retry hint is for answers; reads and lists carry no request_id.. -**List.** `list_request`: `{mind, status, cursor, limit}`. `mind` is the route's `{mind}`, a Mind id from the configured list. `status` is `open`, `recorded` or nil for both; `recorded` means a closed card. Cards come **newest first by `order`**. `order` is the card's unique rank per Mind: an integer from 0 that never changes once the card exists, later questions only appending (Huginn derives it from the Mind's questions sorted by admission ordinal, then key). A Mind never holds two cards of one `order`; a list or card set that does is refused, and the refusal names both fork ids. 0 is legal, but no cursor names it (`c0` fails the cursor pattern): a page ending at order 1 returns `c1`, and the next page returns the order-0 card alone with `next_cursor` nil. `limit` is 0 to 50; **`limit` 0 returns no entries, only the counts and the stamp**, which is what "N open" on the Mind's header needs. `cursor` is nil or `c`: the cards whose `order` is below N; a client treats it as opaque and sends back the `next_cursor` it was given. `list`: `{mind, stamp, open_count, recorded_count, forks[entry], next_cursor}`. The counts cover every card the viewer may see, whatever `status` and `cursor` asked for. `stamp` is `s`, N the receiver's last-seen Huginn admission ordinal for the Mind's desk documents: the same stamp means the same state, so a desk polls `limit` 0 and fetches bodies only when the stamp moves. `next_cursor` is nil on the last page. **A page may hold fewer than `limit` entries** when the next entry would push the encoded response past the receiver's response budget (Huginn: 65536 bytes); it then holds at least one, `next_cursor` is `c` so the next page resumes below it, and the counts and the stamp are those of the full list, unchanged. **`fork_too_large`** (HTTP 422) refuses a page whose **first** entry alone is past the budget: 422 because the request is well formed and authorised but names a resource the list cannot carry, and 413 is for a request body that is too large, which this is not; retrying returns the same refusal. Its `error` names the fork id and its `order` (`fork_too_large: fork (order ) alone is past the response budget`). The client continues the list with cursor `c`, the cards strictly below that order, which skips this fork; it is then readable only by id (the read route returns the whole entry, and refuses with the same code only when that entry alone is past the cap, see Read); `c` is valid only when n is at least 1, so for order 0 there is nothing below and the client has reached the end. `limit` 0 returns no entries and never refuses. (Ruled by Persona, 2026-11-11.) An `entry` is `{card, record}`: the card and its `record` (the answers with their outcomes and the derived `counted_answer_id`). +**List.** `list_request`: `{mind, status, cursor, limit}`. `mind` is the route's `{mind}`, a Mind id from the configured list. `status` is `open`, `recorded` or nil for both; `recorded` means a closed card. Cards come **newest first by `order`**. `order` is the card's unique rank per Mind: an integer from 0 that never changes once the card exists, later questions only appending (Huginn derives it from the Mind's questions sorted by admission ordinal, then key). A Mind never holds two cards of one `order`; a list or card set that does is refused, and the refusal names both fork ids. 0 is legal, but no cursor names it (`c0` fails the cursor pattern): a page ending at order 1 returns `c1`, and the next page returns the order-0 card alone with `next_cursor` nil. `limit` is 0 to 50; **`limit` 0 returns no entries, only the counts and the stamp**, which is what "N open" on the Mind's header needs. `cursor` is nil or `c`: the cards whose `order` is below N; a client treats it as opaque and sends back the `next_cursor` it was given. `list`: `{mind, stamp, open_count, recorded_count, forks[entry], next_cursor}`. The counts cover every card the viewer may see, whatever `status` and `cursor` asked for. `stamp` is `s`, N the receiver's last-seen Huginn admission ordinal for the Mind's desk documents: the same stamp means the same state, so a desk polls `limit` 0 and fetches bodies only when the stamp moves. `next_cursor` is nil on the last page. **A page may hold fewer than `limit` entries** when the next entry would push the encoded response past the receiver's response budget (Huginn: 65536 bytes); it then holds at least one, `next_cursor` is `c` so the next page resumes below it, and the counts and the stamp are those of the full list, unchanged. **`fork_too_large`** (HTTP 422) refuses a page whose **first** entry alone is past the budget: 422 because the request is well formed and authorised but names a resource the list cannot carry, and 413 is for a request body that is too large, which this is not; retrying returns the same refusal. Its `error` names the fork id and its `order`, for example `fork_too_large: fork (order ) alone is past the response budget`. **A refusal's `error` text is informational and not pinned**: the contract pins the code, the HTTP status, that the text names the fork id (and the order on a list), and the `c` continuation on a list; the vectors' wording is a sample, and the judges check those four things, not the bytes of the text. The client continues the list with cursor `c`, the cards strictly below that order, which skips this fork; it is then readable only by id (the read route returns the whole entry, and refuses with the same code only when that entry alone is past the cap, see Read); `c` is valid only when n is at least 1, so for order 0 there is nothing below and the client has reached the end. `limit` 0 returns no entries and never refuses. (Ruled by Persona, 2026-11-11.) An `entry` is `{card, record}`: the card and its `record` (the answers with their outcomes and the derived `counted_answer_id`). -**Read.** `read_request` is `{mind, fork_id}`; the response is the `entry` for one fork of that Mind. Its `card.options_hash` is **computed by the server** from the five shown members as in "Options hash" and the client recomputes it from what it rendered; a card whose hash is not the hash of its members is no card (negative vector `card_hash_wrong`). The record's `fork_id` and `options_hash` are the card's, and `counted_answer_id` is derived, not copied (negative vectors for each). Every `desk.read` holder sees every fork's card and outcome in a configured Mind; only a decider's notes depend on the caller, going to callers in the admin-set `deciders` list, which Bifrost enforces at the door (ruling `desk-read-visibility`, option (c)); `fork_unknown` is only for a fork id that does not exist under the route's Mind (an unknown Mind answers the same, M2), so no fork is ever hidden by it, and a caller without `desk.read` is refused by the auth order above (`insufficient_scope`) before any lookup. **Bifrost applies visibility at the door; Huginn returns the whole record to the door.** Every `desk.read` holder sees cards and outcomes; notes (a decider's own words) only go to callers in the admin-set `deciders` list. The read and list shapes are unchanged and carry no audience field (`eureka-substrate:question:desk-read-visibility`, Heimdall's option (c)). **A read is refused `fork_too_large` (422) when the whole entry is past the reply cap** (Huginn's daemon caps every reply at 65536 bytes; the cap, not the page budget, is what a read meets). Its `error` names the fork id and carries no order and no cursor, because there is nothing to continue with: `fork_too_large: fork alone is past the response budget` (vector `transport.readTooLarge`; the cap is in no vector). A stripped read serves a decider's `note` as `note: null`, never omitted (map-mode: every key is written); `desk_answer.note` is already nullable in the schema, so a served record validates as it stands. +**Read.** `read_request` is `{mind, fork_id}`; the response is the `entry` for one fork of that Mind. Its `card.options_hash` is **computed by the server** from the five shown members as in "Options hash" and the client recomputes it from what it rendered; a card whose hash is not the hash of its members is no card (negative vector `card_hash_wrong`). The record's `fork_id` and `options_hash` are the card's, and `counted_answer_id` is derived, not copied (negative vectors for each). Every `desk.read` holder sees every fork's card and outcome in a configured Mind; only a decider's notes depend on the caller, going to callers in the admin-set `deciders` list, which Bifrost enforces at the door (ruling `desk-read-visibility`, option (c)); `fork_unknown` is only for a fork id that does not exist under the route's Mind (an unknown Mind answers the same, M2), so no fork is ever hidden by it, and a caller without `desk.read` is refused by the auth order above (`insufficient_scope`) before any lookup. **Bifrost applies visibility at the door; Huginn returns the whole record to the door.** Every `desk.read` holder sees cards and outcomes; notes (a decider's own words) only go to callers in the admin-set `deciders` list. The read and list shapes are unchanged and carry no audience field (`eureka-substrate:question:desk-read-visibility`, Heimdall's option (c)). **A read is refused `fork_too_large` (422) when the whole entry is past the reply cap** (Huginn's daemon caps every reply at 65536 bytes; the cap, not the page budget, is what a read meets). Its `error` names the fork id and has no order and no cursor to carry, because there is nothing to continue with (sample: `fork_too_large: fork alone is past the response budget`) (vector `transport.readTooLarge`; the cap is in no vector). **The answer route meets the same cap:** when an answer is `stale_fork`, Bifrost re-reads the current card to attach it, and if that read is past the cap the answer is refused `fork_too_large` (422) with the read form of the text, the same document as a read. Huginn has already stored the answer as `Refused(stale_fork)`, as for any stale answer, and it is never counted; the client is told `fork_too_large` instead of receiving the current card, and a replay of the same `request_id` gets the same 422 while the fork stays oversize. It is never `unavailable` (vector `transport.answerTooLarge`). A stripped read serves a decider's `note` as `note: null`, never omitted (map-mode: every key is written); `desk_answer.note` is already nullable in the schema, so a served record validates as it stands. **Answer.** The body is the `answer_request` and nothing else a client may set: `fork_id`, `options_hash`, `answer`, `answered_at`, `request_id` (plus `retracted` and `retracts`, written `false` and nil unless the call retracts). The response is the stored outcome: `Counted`, `Revision`, `Retraction`, or `Refused` with its code, and the **`counted_answer_id`** the fork has once this call is applied (`n` or nil; derived as in "The counted answer is derived", the same value the `record` projection carries, so the desk needs no second read). A replay returns the stored outcome with the `counted_answer_id` current at the retry, not the one it had when first stored. **A `stale_fork` refusal carries the current card** in `answer_result.card`, so the desk can show the new fork without another read. Nothing is stored for a step 1 or 2 refusal, an unknown fork (or an unknown Mind, or a fork another Mind holds) or a `request_id_conflict`. `unavailable` promises nothing either way. -**Vectors (`transport` in `fork-card-vectors.json`).** `listRequest`, `readRequest` and `entry` (valid and invalid, with bytes); `listInvalid` (including `two_cards_share_one_order`, whose `refusal` is the exact text both judges must produce: `order 4 is shared by and `); `listScenarios` (six scenarios: over one Mind's five cards, paged open-only, recorded-only, all and `limit` 0; an empty Mind; and `order_zero_is_legal_but_no_cursor_names_it`, four cards of orders 3 to 0 paged by 3, whose first page ends at order 1 with `c1` and whose second page is the order-0 card with `next_cursor` nil; each response with its bytes, the cursor of page two being page one's `next_cursor`); `listShortPages` (three scenarios: a page that stops short and resumes below its last entry; `fork_too_large` on a first entry of order 3, continued with `c3`; and a short page ending at order 1 followed by `fork_too_large` on order 0, where the client is at the end; each step carries its bytes, and the receiver's budget is in no vector, so the judges check a short page as a nonempty strict prefix of the unbudgeted page and a refusal as naming the entry the page would open with); `doorFaults` (three cases: a door `request_id_conflict` is `unavailable` 503 on read and list and itself 409 on the answer); `readTooLarge` (one case: a read whose entry is past the cap, refused `fork_too_large` 422 with the read-form text and its bytes); `authCases` (twenty-two cases: the MAS token, three `votes.*` tokens, a desk token with the other route's scope, a non-Active member on each route, and Huginn down on each route, with auth and membership judged first). Both suites replay the list requests through a reference judge and the auth cases through another. `mindRoutes` (a configured list of two Minds with their sockets and forks; 57 cases over the three routes: bad and percent-encoded segments, unknown Mind against unknown fork with `sameResponse` pairs of equal bytes, the POST on a Mind that does not hold the fork, scopes that are not per Mind, and auth judged before the Mind lookup) is replayed by both suites through a reference judge that reports the refusal, the socket it would open and the Mind that stored an answer. Top-level `unavailable` pins the 503 document. Scenarios pin: an `unavailable` where Huginn never had the request, retried into a `Counted` answer; an `unavailable` where Huginn had stored it, whose retry returns the stored outcome and adds no Revision; a replayed `Refused` with its card; `request_id_conflict`; the same id from another account; and a non-Active member with a bad body, with the door down, getting `forbidden` first. A bad body is refused before the door is asked. +**Vectors (`transport` in `fork-card-vectors.json`).** `listRequest`, `readRequest` and `entry` (valid and invalid, with bytes); `listInvalid` (including `two_cards_share_one_order`, whose `refusal` is the exact text both judges must produce: `order 4 is shared by and `); `listScenarios` (six scenarios: over one Mind's five cards, paged open-only, recorded-only, all and `limit` 0; an empty Mind; and `order_zero_is_legal_but_no_cursor_names_it`, four cards of orders 3 to 0 paged by 3, whose first page ends at order 1 with `c1` and whose second page is the order-0 card with `next_cursor` nil; each response with its bytes, the cursor of page two being page one's `next_cursor`); `listShortPages` (three scenarios: a page that stops short and resumes below its last entry; `fork_too_large` on a first entry of order 3, continued with `c3`; and a short page ending at order 1 followed by `fork_too_large` on order 0, where the client is at the end; each step carries its bytes, and the receiver's budget is in no vector, so the judges check a short page as a nonempty strict prefix of the unbudgeted page and a refusal as naming the entry the page would open with); `doorFaults` (three cases: a door `request_id_conflict` is `unavailable` 503 on read and list and itself 409 on the answer); `readTooLarge` (one case: a read whose entry is past the cap, refused `fork_too_large` 422 with the read-form text and its bytes); `answerTooLarge` (one case: a stale answer whose card re-read is past the cap, the same 422 document over a stored `Refused(stale_fork)`: `outcome` `Refused`, `code` `stale_fork`, `counted` false); `authCases` (twenty-two cases: the MAS token, three `votes.*` tokens, a desk token with the other route's scope, a non-Active member on each route, and Huginn down on each route, with auth and membership judged first). Both suites replay the list requests through a reference judge and the auth cases through another. `mindRoutes` (a configured list of two Minds with their sockets and forks; 57 cases over the three routes: bad and percent-encoded segments, unknown Mind against unknown fork with `sameResponse` pairs of equal bytes, the POST on a Mind that does not hold the fork, scopes that are not per Mind, and auth judged before the Mind lookup) is replayed by both suites through a reference judge that reports the refusal, the socket it would open and the Mind that stored an answer. Top-level `unavailable` pins the 503 document. Scenarios pin: an `unavailable` where Huginn never had the request, retried into a `Counted` answer; an `unavailable` where Huginn had stored it, whose retry returns the stored outcome and adds no Revision; a replayed `Refused` with its card; `request_id_conflict`; the same id from another account; and a non-Active member with a bad body, with the door down, getting `forbidden` first. A bad body is refused before the door is asked. **Bifrost must still build:** all three routes; the `bifrost-desk` audience and the `desk.read` and `desk.answer` scopes on the OIDC token it issues to chat; the configured Mind list and its sockets (M1); the projection of cards and records from Huginn, the stamp, the cursor and the counts; the `DeskAnswer` call on the second door and the pass-through of its outcome; the decider list, sent with every answer, read and list request from the admin-set list only (M5); the `unavailable` answer. **Huginn must still build** the second door's wire, the per-call decider list it receives with each answer, read and list request, keeping no copy (M5), and the read side the projection uses (Eureka's cut spec, after her record PR). **The desk may build today** against the vectors: nothing in them needs a live service. @@ -194,4 +194,4 @@ Option B, **Bifrost receives the answer and Huginn stores it** (the design in th 10. The second door's wire between Bifrost and Huginn (pending Eureka's cut spec). `unavailable` is a document of its own, outside the refusal set, so a desk can retry it; calls 1 and 2 use it too when Huginn is down (vectors `huginn_down_*`). 11. While a known Mind's door is down its routes answer `unavailable` (503) and an unknown Mind answers `fork_unknown` (404), so the two are distinguishable for that period. M2 makes them identical only when every door is up; making them identical when one is down means answering `unavailable` for any `{mind}` not on the list, which tells a client nothing about which Minds exist but also hides a typo. Heimdall's to rule; the vectors do not pin it. 12. A fork's answers are unbounded in number, so a fork's entry can grow past the reply cap and become unreadable (`fork_too_large` on read, and on list when it is the first entry of a page). Whether the contract should cap the answers per fork is for Persona and Heimdall to rule; nothing here decides it. -13. The read form of `fork_too_large` is a target, not current behaviour: Huginn's door answers an over-cap read as `Refused(ResponseTooLarge{bytes, limit})`, which carries no fork id and is not `fork_too_large`. Bifrost maps it to `fork_too_large` (422) using the route's `fork_id`; until it does, a read past the cap reaches the client as 503 `unavailable`. +13. The read form of `fork_too_large` is a target, not current behaviour: Huginn's door answers an over-cap read as `Refused(ResponseTooLarge{bytes, limit})`, which carries no fork id and is not `fork_too_large`. Bifrost maps it to `fork_too_large` (422) using the route's `fork_id`; until it does, a read past the cap reaches the client as 503 `unavailable`. The same holds for the answer route: a `stale_fork` answer whose card re-read is past the cap is a Bifrost target too (`fork_too_large` 422 over a stored `Refused(stale_fork)`), and until it is built the client sees 503 `unavailable`. diff --git a/packages/cultnet-ts/test/governance-vectors.test.ts b/packages/cultnet-ts/test/governance-vectors.test.ts index 6d4769bd..d77ca936 100644 --- a/packages/cultnet-ts/test/governance-vectors.test.ts +++ b/packages/cultnet-ts/test/governance-vectors.test.ts @@ -532,7 +532,7 @@ test("fork transport: a page may stop short of limit, and a first entry past the assert.equal(r.code, "fork_too_large"); assert.equal(step.httpStatus, 422); assert.equal(step.fork_id, first.fork_id); assert.equal(step.order, first.order); - assert.equal(r.error, `fork_too_large: fork ${first.fork_id} (order ${first.order}) alone is past the response budget`); + assert.ok(r.error.includes(first.fork_id) && new RegExp(`\\b${first.order}\\b`).test(r.error.replace(first.fork_id, "")), `${s.name}: the text names the fork id and the order`); assert.ok(step.request.limit >= 1, "limit 0 never refuses"); if (first.order >= 1) { assert.ok(i + 1 < s.steps.length, s.name); last = `c${first.order}`; assert.ok(check(forkSchema, "cursor", last)); } else { assert.equal(i, s.steps.length - 1, `${s.name}: order 0 is the end`); assert.equal(check(forkSchema, "cursor", "c0"), false); last = undefined; } @@ -553,13 +553,29 @@ test("fork transport: a read whose entry is past the cap is fork_too_large (422) assert.ok(held !== undefined && check(forkSchema, "readRequest", c.request), c.name); assert.ok(check(forkSchema, "refusal", c.refusal), c.name); assert.equal(c.refusal.code, "fork_too_large"); - assert.equal(c.refusal.error, `fork_too_large: fork ${held.card.fork_id} alone is past the response budget`); - assert.ok(!c.refusal.error.includes("order"), "a read names no order"); + assert.ok(c.refusal.error.includes(held.card.fork_id), "the text names the fork id"); assert.equal(hexOf(c.refusal), c.refusalHex, c.name); assert.equal(c.httpStatus, 422); assert.equal(c.httpStatus, doorStatus); } }); +test("fork transport: a stale answer whose card re-read is past the cap is the read-form fork_too_large (422), over a stored Refused(stale_fork)", () => { + assert.equal(fork.transport.answerTooLarge.length, 1); + const read = fork.transport.readTooLarge[0]; + for (const c of fork.transport.answerTooLarge) { + const held = c.entries.find((e: any) => e.card.fork_id === c.request.fork_id); + assert.ok(held !== undefined && check(forkSchema, "answerRequest", c.request), c.name); + assert.notEqual(c.request.options_hash, held.card.options_hash, "the card moved: the answer is stale"); + assert.ok(check(forkSchema, "refusal", c.refusal), c.name); + assert.equal(c.refusal.code, "fork_too_large"); + assert.ok(c.refusal.error.includes(held.card.fork_id), "the text names the fork id"); + assert.equal(hexOf(c.refusal), c.refusalHex, c.name); + assert.equal(c.refusal.code, read.refusal.code, "the same refusal as the read form"); + assert.equal(c.httpStatus, 422); assert.equal(c.httpStatus, fork.doorRefusals.find((r: any) => r.name === "fork_too_large").httpStatus); + assert.equal(c.outcome, "Refused"); assert.equal(c.code, "stale_fork"); assert.equal(c.counted, false); + } +}); + test("fork transport: a door request_id_conflict is a fault on read and list (503 unavailable) and a refusal only on the answer", () => { assert.deepEqual(fork.transport.doorFaults.map((c: any) => c.route), ["read", "list", "answer"]); for (const c of fork.transport.doorFaults) { diff --git a/scripts/gen-governance-vectors.py b/scripts/gen-governance-vectors.py index 914cfcee..ec2a269a 100644 --- a/scripts/gen-governance-vectors.py +++ b/scripts/gen-governance-vectors.py @@ -430,6 +430,11 @@ def read_too_large_doc(c): return {"schemaVersion": "gamecult.fork.refusal.v0", READ_TOO_LARGE = [{"name": "a_read_whose_entry_is_past_the_cap_is_fork_too_large_without_an_order", "ordinal": 9, "entries": ORDER0, "request": {"schemaVersion": "gamecult.fork.read_request.v0", "mind": "gamecult-chat", "fork_id": ORDER0[0]["card"]["fork_id"]}, "refusal": read_too_large_doc(ORDER0[0]["card"]), "refusalHex": hx(read_too_large_doc(ORDER0[0]["card"])), "httpStatus": HTTP["fork_too_large"], "fork_id": ORDER0[0]["card"]["fork_id"]}] +# An answer on a stale card whose current-card re-read is past the cap: the same refusal document as the read form over a stored Refused(stale_fork), never counted. +_big = ORDER0[0]["card"] +ANSWER_TOO_LARGE = [{"name": "a_stale_answer_whose_card_reread_is_past_the_cap_is_fork_too_large_over_a_stored_stale_refusal", + "entries": ORDER0, "request": body("yes", NOTE, h=STALE_H, fork=_big["fork_id"], rid="0000a001-0000-4000-8000-00000000a001"), + "refusal": read_too_large_doc(_big), "refusalHex": hx(read_too_large_doc(_big)), "httpStatus": HTTP["fork_too_large"], "outcome": "Refused", "code": "stale_fork", "counted": False, "fork_id": _big["fork_id"]}] def short_step(entries, ordinal, rq, keep): full = judge_list(entries, ordinal, rq); page = full["forks"][:keep] assert 0 < keep < len(full["forks"]) @@ -642,6 +647,7 @@ fork = { "listScenarios": [{"name": l["name"], "ordinal": l["ordinal"], "entries": l["entries"], "steps": l["steps"]} for l in LISTS], "listShortPages": SHORT_PAGES, "readTooLarge": READ_TOO_LARGE, + "answerTooLarge": ANSWER_TOO_LARGE, "doorFaults": [dict(c, response=r, responseHex=hx(r)) for c, r in [({"name": f"{x}_door_answers_request_id_conflict_the_client_sees_unavailable", "route": x, "doorCode": "request_id_conflict", "httpStatus": 503}, unavailable_doc(30)) for x in ("read", "list")] + [({"name": "answer_door_answers_request_id_conflict_the_client_sees_it", "route": "answer", "doorCode": "request_id_conflict", "httpStatus": HTTP["request_id_conflict"]}, refusal_doc("request_id_conflict"))]], diff --git a/tests/GameCult.Networking.Tests/GovernanceVectorTests.cs b/tests/GameCult.Networking.Tests/GovernanceVectorTests.cs index 58ce7041..57e1cf84 100644 --- a/tests/GameCult.Networking.Tests/GovernanceVectorTests.cs +++ b/tests/GameCult.Networking.Tests/GovernanceVectorTests.cs @@ -730,7 +730,8 @@ namespace GameCult.Networking.Tests Assert.That(HexOf(r), Is.EqualTo(S(step["refusalHex"])), name); Assert.That(S(r["code"]), Is.EqualTo("fork_too_large")); Assert.That(step["httpStatus"]!.GetValue(), Is.EqualTo(422)); Assert.That(S(step["fork_id"]), Is.EqualTo(S(first["fork_id"]))); Assert.That(step["order"]!.GetValue(), Is.EqualTo(first["order"]!.GetValue())); - Assert.That(S(r["error"]), Is.EqualTo($"fork_too_large: fork {S(first["fork_id"])} (order {first["order"]!.GetValue()}) alone is past the response budget")); + Assert.That(S(r["error"]), Does.Contain(S(first["fork_id"])), name); + Assert.That(System.Text.RegularExpressions.Regex.IsMatch(S(r["error"]).Replace(S(first["fork_id"]), ""), $@"\b{first["order"]!.GetValue()}\b"), Is.True, $"{name}: the text names the order"); Assert.That(step["request"]!["limit"]!.GetValue(), Is.GreaterThanOrEqualTo(1)); var order = first["order"]!.GetValue(); if (order >= 1) { Assert.That(i + 1, Is.LessThan(steps.Count), name); last = $"c{order}"; follow = true; Assert.That(Errors(ForkSchema, "cursor", JsonValue.Create(last)), Is.Empty); } @@ -754,13 +755,34 @@ namespace GameCult.Networking.Tests Assert.That(Errors(ForkSchema, "readRequest", c["request"]), Is.Empty, S(c["name"])); Assert.That(Errors(ForkSchema, "refusal", c["refusal"]), Is.Empty, S(c["name"])); Assert.That(S(c["refusal"]!["code"]), Is.EqualTo("fork_too_large")); - Assert.That(S(c["refusal"]!["error"]), Is.EqualTo($"fork_too_large: fork {S(held["card"]!["fork_id"])} alone is past the response budget")); - Assert.That(S(c["refusal"]!["error"]), Does.Not.Contain("order"), "a read names no order"); + Assert.That(S(c["refusal"]!["error"]), Does.Contain(S(held["card"]!["fork_id"])), "the text names the fork id"); Assert.That(HexOf(c["refusal"]), Is.EqualTo(S(c["refusalHex"])), S(c["name"])); Assert.That(c["httpStatus"]!.GetValue(), Is.EqualTo(422)); Assert.That(c["httpStatus"]!.GetValue(), Is.EqualTo(doorStatus)); } } + [Test] + public void ForkTransport_AStaleAnswerWhoseCardRereadIsPastTheCap_IsTheReadFormForkTooLarge422_OverAStoredStaleRefusal() + { + var cases = Fork["transport"]!["answerTooLarge"]!.AsArray(); + Assert.That(cases.Count, Is.EqualTo(1)); + var read = Fork["transport"]!["readTooLarge"]![0]!; + foreach (var c in cases) + { + var held = c!["entries"]!.AsArray().Single(e => S(e!["card"]!["fork_id"]) == S(c["request"]!["fork_id"]))!; + Assert.That(Errors(ForkSchema, "answerRequest", c["request"]), Is.Empty, S(c["name"])); + Assert.That(S(c["request"]!["options_hash"]), Is.Not.EqualTo(S(held["card"]!["options_hash"])), "the card moved: the answer is stale"); + Assert.That(Errors(ForkSchema, "refusal", c["refusal"]), Is.Empty, S(c["name"])); + Assert.That(S(c["refusal"]!["code"]), Is.EqualTo("fork_too_large")); + Assert.That(S(c["refusal"]!["error"]), Does.Contain(S(held["card"]!["fork_id"])), "the text names the fork id"); + Assert.That(HexOf(c["refusal"]), Is.EqualTo(S(c["refusalHex"])), S(c["name"])); + Assert.That(S(c["refusal"]!["code"]), Is.EqualTo(S(read["refusal"]!["code"])), "the same refusal as the read form"); + Assert.That(c["httpStatus"]!.GetValue(), Is.EqualTo(422)); + Assert.That(c["httpStatus"]!.GetValue(), Is.EqualTo(Fork["doorRefusals"]!.AsArray().Single(r => S(r!["name"]) == "fork_too_large")!["httpStatus"]!.GetValue())); + Assert.That(S(c["outcome"]), Is.EqualTo("Refused")); Assert.That(S(c["code"]), Is.EqualTo("stale_fork")); Assert.That(c["counted"]!.GetValue(), Is.False); + } + } + [Test] public void ForkTransport_ADoorRequestIdConflict_IsAFaultOnReadAndList_AndARefusalOnlyOnTheAnswer() { From 7760219b7c0e04321f963bfb8ff3446e0e7131db Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 03:48:08 +0000 Subject: [PATCH 07/93] CultCache: [CultIndex] is unique at commit and load; [CultRole] names a selection role; replicas apply a commit's changes as one batch (#90) --- .../cultnet/cultnet-distributed-database.md | 6 + .../cultnet.database-change-raw.schema.json | 4 +- docs/start/README.md | 2 +- docs/studio-grouping-cut.md | 6 +- .../Contracts/cultcache-store-composition.md | 5 +- src/GameCult.Caching/CultCache.cs | 174 ++--- src/GameCult.Caching/CultDocumentContracts.cs | 14 + src/GameCult.Caching/README.md | 12 +- .../CultGeometryDocuments.cs | 6 +- src/GameCult.Mesh/CultMeshBodyPublications.cs | 2 +- src/GameCult.Mesh/CultMeshCdn.cs | 6 +- src/GameCult.Mesh/CultMeshEntityPrefabs.cs | 8 +- src/GameCult.Mesh/CultMeshSimulationFacts.cs | 10 +- src/GameCult.Mesh/docs/public-api.md | 13 +- src/GameCult.Networking/CultNetDatabase.cs | 72 +- .../CultNetDatabaseServer.cs | 35 +- .../CultNetDatabaseSubscriptionClient.cs | 149 ++++- .../CultNetDatabaseSubscriptionServer.cs | 129 +++- .../CultNetDocumentRegistry.cs | 70 +- .../CultNetSchemaMessages.cs | 11 + .../CultDocumentSelectionSurfaceTests.cs | 24 +- .../DocumentShapeTests.cs | 2 +- .../EmittedDocumentTypes.cs | 6 +- .../GameCult.Caching.Tests/FirstCallTests.cs | 2 +- .../UniqueIndexTests.cs | 459 +++++++++++++ tests/GameCult.Caching.Tests/VariantTests.cs | 128 +--- .../CultNetReplicaCommitTests.cs | 621 ++++++++++++++++++ .../CultNetSelectionEvaluatorTests.cs | 37 +- .../CultNetSelectionFixBatchTests.cs | 2 +- .../CultNetSelectionPageTests.cs | 2 +- .../CultNetSelectionSurvivorTests.cs | 12 +- .../SelectionParityVectorTests.cs | 6 +- 32 files changed, 1686 insertions(+), 349 deletions(-) create mode 100644 tests/GameCult.Caching.Tests/UniqueIndexTests.cs create mode 100644 tests/GameCult.Networking.Tests/CultNetReplicaCommitTests.cs diff --git a/contracts/cultnet/cultnet-distributed-database.md b/contracts/cultnet/cultnet-distributed-database.md index 932ba861..1ddc80e8 100644 --- a/contracts/cultnet/cultnet-distributed-database.md +++ b/contracts/cultnet/cultnet-distributed-database.md @@ -140,6 +140,12 @@ A malformed put or delete is `message_invalid`, not a per-operation code: the pe - A shard snapshot represents a log sequence. Applying it replaces the local shard view and advances the replica cursor to that sequence. - Realtime subscriptions publish domain changes, not storage envelopes. +- A subscription's `database_change_raw` messages may carry `commitId` and `commitCount`: the changes of one cache + commit that this subscription delivers (`commitCount` of them, at least two). Both fields are optional and additive; a + peer that does not know them ignores them and applies each change alone. A replica that knows them applies the + commit's changes as one commit, so the replica judges the result (a swap of two unique-index values) and not the state + between records. A replica whose cache refuses a commit is faulted, never silent. `commitId` is opaque and valid only + within the subscription. The C# subscription server and database server tag commits; other runtimes adopt next. - Client-owned input documents may be predicted locally only inside explicit `CultNetClientAuthorityScope` declarations. - Predicted input state is not authoritative shared state. The shard log still diff --git a/contracts/cultnet/cultnet.database-change-raw.schema.json b/contracts/cultnet/cultnet.database-change-raw.schema.json index 2725a5af..d214ed6b 100644 --- a/contracts/cultnet/cultnet.database-change-raw.schema.json +++ b/contracts/cultnet/cultnet.database-change-raw.schema.json @@ -12,7 +12,9 @@ "changeKind": { "enum": ["added", "updated", "removed"] }, "document": { "$ref": "./cultnet.raw-document-record.schema.json" }, "recordKey": { "type": "string", "minLength": 1 }, - "schemaId": { "type": "string", "minLength": 1 } + "schemaId": { "type": "string", "minLength": 1 }, + "commitId": { "type": "string", "minLength": 1 }, + "commitCount": { "type": "integer", "minimum": 2 } }, "allOf": [ { diff --git a/docs/start/README.md b/docs/start/README.md index e033c935..c388f8b6 100644 --- a/docs/start/README.md +++ b/docs/start/README.md @@ -44,7 +44,7 @@ using MessagePack; public sealed class Item { [Key(0)] [CultName] public string Name = string.Empty; - [Key(1)] [CultIndex] public string Category = string.Empty; + [Key(1)] public string Category = string.Empty; [Key(2)] public int Value; } diff --git a/docs/studio-grouping-cut.md b/docs/studio-grouping-cut.md index 770d0698..7bc8418c 100644 --- a/docs/studio-grouping-cut.md +++ b/docs/studio-grouping-cut.md @@ -33,9 +33,9 @@ Recovered from the legacy "Database Tools" window (Aetheria `d3db1730`, **The attribute is not `[CultIndex]`.** `[CultIndex]` means unique (operator, 2026-09-30; `CultDocumentContracts.cs:34`). Grouping is `CultInspectorGroupByAttribute`, in `CultInspectorAttributes.cs`, read only by `CultInspectorModel`. It changes no schema id, -catalog entry, index, or store byte. The index cut's deletion of grouping-only `[CultIndex]` -declarations (CultMesh, Geometry) adds no `[CultInspectorGroupBy]` in their place: those are -service stores, not Studio-authored catalogs. +catalog entry, index, or store byte. The index cut converts the grouping and selection `[CultIndex]` +declarations (CultMesh, Geometry) to `[CultRole]` rather than deleting them, and adds no +`[CultInspectorGroupBy]` in their place: those are service stores, not Studio-authored catalogs. ### Invariants diff --git a/src/GameCult.Caching/Contracts/cultcache-store-composition.md b/src/GameCult.Caching/Contracts/cultcache-store-composition.md index 1a03d4b1..01f7a5c9 100644 --- a/src/GameCult.Caching/Contracts/cultcache-store-composition.md +++ b/src/GameCult.Caching/Contracts/cultcache-store-composition.md @@ -110,7 +110,10 @@ which route wrote it. already agree when publication starts. - An admission runs a record's `[CultName]` and `[CultIndex]` getters once per landing record, before the store is written and before any `Sequence` is - minted. A conditional commit on a clean file store is judged a second time + minted. An admission is refused when it would leave two records of one type + holding one `[CultIndex]` value (blank is no value), and a load of a store + holding such a pair refuses naming both keys and the index. Concurrent writers on a + directory store are not covered by this rule. A conditional commit on a clean file store is judged a second time against the file it merges onto whenever the cache holds variants in any store, the batch lands a variant, or the file holds one. That second read runs the getters again for the records the batch lands, for any variant it re-resolves, diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index 5a0626a6..709fc64d 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -885,6 +885,10 @@ namespace GameCult.Caching return indexes.BySchemaId.ContainsKey(schemaId) || indexes.ByCompatibleSchemaId.ContainsKey(schemaId); } + // Whether the type registered under this schema id declares a [CultIndex]: its records are written through a cache, which judges uniqueness. + internal bool HoldsUniqueIndex(string schemaId) => + _indexes.BySchemaId.TryGetValue(schemaId, out var descriptor) && descriptor.IndexAccessors.Count > 0; + // A persisted entry a local entry reads whole: identical content, or compatible shapes that leave no persisted slot ignored. internal static bool ReadsWhole(CultSchemaCatalogEntry persisted, CultSchemaCatalogEntry local) => string.Equals(persisted.ContentHash, local.ContentHash, StringComparison.Ordinal) || @@ -1006,10 +1010,10 @@ namespace GameCult.Caching throw new InvalidOperationException(ids.Problems[0]); var nameMember = members.FirstOrDefault(member => member.IsName); var indexAccessors = members - .Where(member => member.IndexAlias != null) + .Where(member => member.IsUnique) .ToDictionary( member => member.IndexAlias!, - member => member.Getter, + member => member.IndexValue, StringComparer.Ordinal); var descriptorMembers = members .Select(member => new CultDocumentMemberDescriptor @@ -1481,6 +1485,13 @@ namespace GameCult.Caching rejections.Add(HiddenMemberMessage(type.Name, Qualified(pair[0].Member), Qualified(pair[1].Member))); } + // A member names its role once: [CultIndex] already is a role. + foreach (var entry in keyed.OrderBy(entry => entry.Member.Name, StringComparer.Ordinal)) + { + if (entry.Member.GetCustomAttribute() != null && entry.Member.GetCustomAttribute() != null) + rejections.Add($"Cult document {type.Name} member {Qualified(entry.Member)} declares both [CultIndex] and [CultRole]; [CultIndex] already names its role."); + } + // D10: two differently-named members that independently declare the same index alias. A // hiding member sharing one slot is already caught above; this is two distinct members whose // alias map entry (BuildDescriptor's indexAccessors) would otherwise collide silently. @@ -1580,14 +1591,19 @@ namespace GameCult.Caching private static string Qualified(MemberInfo member) => member.DeclaringType!.Name.Split('`')[0] + "." + member.Name; + // The member's role alias, from [CultIndex] or [CultRole] (the member name when the alias is blank). // Shared by DiscoverMembers' D10/D11 rejections and PersistedMember.FromMember, so registration // refuses exactly the shapes the accessor it builds afterward could not have walked anyway. private static string? ResolveIndexAlias(MemberInfo member) { - var indexAttribute = member.GetCustomAttribute(); - if (indexAttribute == null) + string? alias; + if (member.GetCustomAttribute() is { } index) + alias = index.Alias; + else if (member.GetCustomAttribute() is { } role) + alias = role.Alias; + else return null; - return string.IsNullOrWhiteSpace(indexAttribute.Alias) ? member.Name : indexAttribute.Alias; + return string.IsNullOrWhiteSpace(alias) ? member.Name : alias; } private static bool IsCultRecordRefType(Type type) => @@ -1854,6 +1870,7 @@ namespace GameCult.Caching public int Slot { get; set; } public bool IsName { get; set; } public string? IndexAlias { get; set; } + public bool IsUnique { get; set; } public bool IsReference { get; set; } public bool IsMany { get; set; } public string? TargetSchemaName { get; set; } @@ -1866,6 +1883,10 @@ namespace GameCult.Caching public Func Getter { get; set; } = default!; public Func GetterNullable { get; set; } = default!; + // What a [CultIndex] value is: culture-invariant, and a number is its canonical decimal (1.0m and 1.00m are one value, + // 0.0 and -0.0 are one value), so the value that decides a refusal is the value a lookup names. + public Func IndexValue { get; set; } = default!; + // D8/Q-J (revised 2026-09-22): non-null exactly for the closed CLR numeric set (Nullable // unwrapped), rendered as the member's exact canonical decimal string - never a double, so // no CLR numeric type's range or precision is lost rendering it. docs/cultnet-selection-cut.md @@ -1896,6 +1917,7 @@ namespace GameCult.Caching Slot = slot, IsName = member.GetCustomAttribute() != null, IndexAlias = ResolveIndexAlias(member), + IsUnique = member.GetCustomAttribute() != null, IsReference = isReference, IsMany = many, TargetSchemaName = targetSchemaName, @@ -1903,6 +1925,13 @@ namespace GameCult.Caching IsNumeric = IsNumericClrType(memberType), Getter = document => getValue(document)?.ToString() ?? string.Empty, GetterNullable = document => getValue(document)?.ToString(), + IndexValue = document => getValue(document) switch + { + null => string.Empty, + var raw when IsNumericClrType(raw.GetType()) => RenderCanonicalNumber(raw) ?? string.Empty, + IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture), + var raw => raw.ToString() ?? string.Empty + }, NumberOf = document => { var raw = getValue(document); @@ -2200,8 +2229,8 @@ namespace GameCult.Caching private readonly List<(CacheBackingStore Store, Type[] Homes)> _stores = new(); private readonly Dictionary _entries = new(StringComparer.Ordinal); private readonly Dictionary> _names = new(); - // Every key holding a value (unordered): the index is truthful about duplicates, so removing one holder never hides another. - private readonly Dictionary<(Type Type, string Alias), Dictionary>> _indexes = new(); + // One key per value: RefuseIndexSharing decides it, Apply only records it. + private readonly Dictionary<(Type Type, string Alias), Dictionary> _indexes = new(); private readonly Dictionary _globals = new(); private readonly CultCodec? _codec; // Keys holding a variant: derived from the entries by Apply, never decided anywhere else. @@ -2671,16 +2700,12 @@ namespace GameCult.Caching .SelectMany(pair => pair.Value.TryGetValue(name, out var key) ? new[] { key } : Array.Empty())); } - // A value held by one record finds it. Records of one type sharing a value (plain duplicates) find the holder with the - // smallest key (ordinal), whatever order they were written or loaded in: the choice is a function of the held set, so - // it is the same live, after a flush and after a reopen, and removing that holder finds the next. + // A [CultIndex] value names one record of its type; blank values are not indexed. public T? GetByIndex(string alias, string value) where T : class { return Single($"index {alias}='{value}'", () => _indexes .Where(pair => pair.Key.Alias == alias && typeof(T).IsAssignableFrom(pair.Key.Type)) - .SelectMany(pair => pair.Value.TryGetValue(value, out var keys) - ? new[] { keys.Aggregate((best, next) => string.CompareOrdinal(next, best) < 0 ? next : best) } - : Array.Empty())); + .SelectMany(pair => pair.Value.TryGetValue(value, out var key) ? new[] { key } : Array.Empty())); } public void Dispose() @@ -3270,12 +3295,10 @@ namespace GameCult.Caching { var plan = ResolveVariants(admitted, evicted, source); Project(plan); - // The variant rules are judged only when a variant is involved. + RefuseIndexSharing(plan, evicted); + // The variant name rule is judged only when a variant is involved. if (_variantKeys.Count != 0 || admitted.Any(stored => stored.Variant != null)) - { RefuseVariantNameSharing(plan); - RefuseVariantIndexSharing(plan, evicted); - } return plan; } @@ -3472,62 +3495,41 @@ namespace GameCult.Caching } } - // The one index rule, judged on the commit's result whatever the write order: a variant may not share an indexed - // value with another record. It reads the truthful held index (every holder of a value), minus the records this - // commit replaces or removes, plus the records it lands. Plain-vs-plain sharing is not this rule's business. - // A unique index maps a value to one key at lookup; a variant sharing it would be found in place of its - // holder or lose the value, and the store would refuse to reopen. - private void RefuseVariantIndexSharing(VariantPlan plan, IReadOnlyList evicted) + // The one index rule, judged on the commit's result whatever the write order: within one document type and one + // [CultIndex] alias, no two records hold one non-blank value. The holders of a value are the held key (unless this + // admission replaces or evicts it) plus every landing key projecting it; two distinct holders refuse, naming both + // keys and the index and never the value. One pass over what lands, one lookup per (index, value). + private void RefuseIndexSharing(VariantPlan plan, IReadOnlyList evicted) { var landing = plan.Admitted.Concat(plan.Dependents).ToList(); if (landing.All(stored => stored.Descriptor.IndexAccessors.Count == 0)) return; var replaced = new HashSet( plan.Admitted.Concat(evicted).Concat(plan.Dependents).Select(stored => stored.Key.Value), StringComparer.Ordinal); - var landingByKey = landing.ToDictionary(stored => stored.Key.Value, StringComparer.Ordinal); - - bool IsVariant(string key) => - landingByKey.TryGetValue(key, out var landed) ? landed.Variant != null : _variantKeys.Contains(key); - - // One pass over what lands, one lookup per (index, value): the rule is linear in the records landing. - var landedValues = new List<(CultStoredDocument Stored, (Type Type, string Alias) Index, string Value)>(); var landed = new Dictionary<((Type Type, string Alias) Index, string Value), List>(); + var schemaNames = landing.Select(stored => stored.Descriptor).GroupBy(descriptor => descriptor.DocumentType) + .ToDictionary(group => group.Key, group => group.First().SchemaName); foreach (var stored in landing) { foreach (var (alias, value) in plan.Projections[stored.Key.Value].Indexes) { - var index = (stored.Descriptor.DocumentType, alias); - landedValues.Add((stored, index, value)); - if (!landed.TryGetValue((index, value), out var landedKeys)) - landed[(index, value)] = landedKeys = new List(); - landedKeys.Add(stored.Key.Value); + var slot = ((stored.Descriptor.DocumentType, alias), value); + if (!landed.TryGetValue(slot, out var keys)) + landed[slot] = keys = new List(); + keys.Add(stored.Key.Value); } } - var holdersOf = new Dictionary<((Type Type, string Alias) Index, string Value), (string[] All, string[] Variants)>(); - foreach (var (stored, index, value) in landedValues) + foreach (var ((index, value), landedKeys) in landed.OrderBy(pair => pair.Key.Index.Alias, StringComparer.Ordinal)) { - if (!holdersOf.TryGetValue((index, value), out var known)) - { - var all = (_indexes.TryGetValue(index, out var held) && held.TryGetValue(value, out var heldKeys) - ? heldKeys.Where(heldKey => !replaced.Contains(heldKey)) - : Enumerable.Empty()) - .Concat(landed[(index, value)]) - .Distinct(StringComparer.Ordinal) - .ToArray(); - holdersOf[(index, value)] = known = (all, all.Where(IsVariant).ToArray()); - } - - var self = stored.Key.Value; - var holder = known.All.FirstOrDefault(candidate => candidate != self); - if (stored.Variant != null && holder != null) + var holders = landedKeys.AsEnumerable(); + if (_indexes.TryGetValue(index, out var held) && held.TryGetValue(value, out var heldKey) && !replaced.Contains(heldKey)) + holders = holders.Append(heldKey); + var distinct = holders.Distinct(StringComparer.Ordinal).OrderBy(key => key, StringComparer.Ordinal).Take(2).ToArray(); + if (distinct.Length > 1) throw new InvalidOperationException( - $"Variant {self} and {holder} both hold index {index.Alias} '{value}'; a variant must override its indexed member."); - var variantHolder = known.Variants.FirstOrDefault(candidate => candidate != self); - if (stored.Variant == null && variantHolder != null) - throw new InvalidOperationException( - $"Record {self} and variant {variantHolder} both hold index {index.Alias} '{value}'; " + - "a variant may not share an indexed value with another record."); + $"Records {distinct[0]} and {distinct[1]} ({schemaNames[index.Type]}) " + + $"both hold unique index '{index.Alias}'; an index value names one record."); } } @@ -3633,7 +3635,7 @@ namespace GameCult.Caching if (projection.Name != null) MapOf(_names, type)[projection.Name] = stored.Key.Value; foreach (var (alias, value) in projection.Indexes) - AddHolder(_indexes, (type, alias), value, stored.Key.Value); + MapOf(_indexes, (type, alias))[value] = stored.Key.Value; } // Documents are mutable, so a name or index value may have changed since it was indexed: drop by key. @@ -3645,26 +3647,7 @@ namespace GameCult.Caching if (_names.TryGetValue(type, out var names)) RemoveKey(names, stored.Key.Value); foreach (var pair in _indexes.Where(pair => pair.Key.Type == type)) - RemoveHolder(pair.Value, stored.Key.Value); - } - - private static void AddHolder(Dictionary<(Type Type, string Alias), Dictionary>> indexes, (Type, string) index, string value, string key) - { - if (!indexes.TryGetValue(index, out var map)) - indexes[index] = map = new Dictionary>(StringComparer.Ordinal); - if (!map.TryGetValue(value, out var keys)) - map[value] = keys = new List(); - keys.Remove(key); - keys.Add(key); - } - - private static void RemoveHolder(Dictionary> map, string key) - { - foreach (var pair in map.ToArray()) - { - if (pair.Value.Remove(key) && pair.Value.Count == 0) - map.Remove(pair.Key); - } + RemoveKey(pair.Value, stored.Key.Value); } private static Dictionary MapOf(Dictionary> maps, TKey key) @@ -4648,13 +4631,17 @@ namespace GameCult.Caching /// Whether the record's payload holds element ids, or null when the caller cannot tell. /// The registry the payload was encoded with. Given, the write decides the header for copied records that registry can read. It never decides ownership: the answer is the same lent or not. alone owns the record at the key when it declares the stored id (its own or one it lists compatible), or when it is the type that reads the stored entry and reads it whole, so an older id of its own schema name it reads whole is its own and is restamped. Everything else is refused with the file left as it was: a record of another registered type included, whatever the registry holds, and a record it would read only by dropping persisted slots. Retyping a key is a removal and an add. It serves this call only: the store keeps no registry. /// The call holds the store's gate while it runs. User code it runs must not enter a cache or another store; that throws . - /// The store is attached to a cache; write through the cache. + /// The store is attached to a cache; write through the cache. Also thrown when the record's type declares a , and when the catalog entry declares an index alias and the registry lent for the call does not hold the type (none, or another): the call cannot tell a unique index from a role, so such records are written through a cache. /// The file at the path is not a store this runtime can read. public void WriteRecord(CultPersistedRecord record, CultSchemaCatalogEntry catalogEntry, bool? holdsIds = null, CultDocumentRegistry? registry = null) { if (record == null) throw new ArgumentNullException(nameof(record)); if (catalogEntry == null) throw new ArgumentNullException(nameof(catalogEntry)); - Cacheless(registry, () => WriteOne(record, catalogEntry, holdsIds)); + Cacheless(registry, () => + { + RefuseUniqueIndexedCacheless(AttachedRegistry, catalogEntry); + WriteOne(record, catalogEntry, holdsIds); + }); } /// @@ -4671,7 +4658,7 @@ namespace GameCult.Caching /// The stored-at stamp, or null for now. /// The registry that describes and encodes the document, or null for . The document type's entry is taken from it, and the registry decides the header for copied records it can read. It never decides ownership, so the answer is the same for any registry that holds the type: the document's entry owns a record whose id it declares (its own or one it lists compatible), and a record of an older id of its own schema name that it reads whole; everything else is refused, another registered type's record included, and a record it would read only by dropping persisted slots. Retyping a key is a removal and an add. It serves this call only: the store keeps no registry. /// The call holds the store's gate while it runs. User code it runs (property getters while the document is encoded, constructors while a copied record is read) must not enter a cache or another store; that throws , carried by the encoder's wrapper when the code ran inside it. - /// The store is attached to a cache; write through the cache. + /// The store is attached to a cache; write through the cache. Also thrown when the record's type declares a , and when the catalog entry declares an index alias and the registry lent for the call does not hold the type (none, or another): the call cannot tell a unique index from a role, so such records are written through a cache. /// The file at the path is not a store this runtime can read. /// The document holds an element id or list a cache write refuses. public void WriteDocument(CultRecordKey key, TDocument document, string? storedAt = null, CultDocumentRegistry? registry = null) @@ -4680,6 +4667,7 @@ namespace GameCult.Caching if (document == null) throw new ArgumentNullException(nameof(document)); var resolvedRegistry = registry ?? CultDocumentRegistry.Shared; var catalogEntry = resolvedRegistry.GetRequired().ToCatalogEntry(); + RefuseUniqueIndexedCacheless(resolvedRegistry, catalogEntry); Cacheless(resolvedRegistry, () => { var ids = CultElementIds.Plan(typeof(TDocument), document, key.Value, deterministic: false); @@ -4705,6 +4693,19 @@ namespace GameCult.Caching }); } + // A unique index is judged against every held record, which a cacheless write does not hold. A write is refused when the + // registry lent for the call holds a type with a [CultIndex] under the schema id, and also when the entry declares an index + // alias the call cannot classify (no registry, or one not holding the type): the catalog does not say unique from role. + private static void RefuseUniqueIndexedCacheless(CultDocumentRegistry? registry, CultSchemaCatalogEntry entry) + { + var unique = registry != null && registry.HoldsUniqueIndex(entry.SchemaId); + var unclassified = entry.Members.Any(member => member.IndexAlias != null) && (registry == null || !registry.Declared(entry.SchemaId)); + if (unique || unclassified) + throw new InvalidOperationException( + "A record of a type with a unique index is written through a cache; a cacheless write cannot judge uniqueness" + + (unclassified ? " and the registry lent for the call does not say whether this schema's index is unique." : ".")); + } + private void WriteOne(CultPersistedRecord record, CultSchemaCatalogEntry catalogEntry, bool? holdsIds) { using (AcquireLock(wait: true)) @@ -4727,7 +4728,7 @@ namespace GameCult.Caching RefuseUndeclaredOnDisk(durable, upserts, removals, checks); if (upserts.Count == 0 && removals.Count == 0 && (checks.Any() || StagedChecks.Count > 0)) return Array.Empty<(CultStagedRecord, string)>(); - RefuseWriteIntoMovedVariantStore(durable, upserts, removals); + RefuseWriteIntoMovedStore(durable, upserts, removals); var durableRecords = durable?.Snapshot.Records ?? Array.Empty(); // A record written over one the store holds is stored at a storedAt later than the one it replaces, whoever wrote that. var replaced = durableRecords.ToDictionary(record => record.Key, record => record.StoredAt, StringComparer.Ordinal); @@ -4812,10 +4813,11 @@ namespace GameCult.Caching return false; } - private void RefuseWriteIntoMovedVariantStore(Durable? durable, IReadOnlyCollection upserts, IReadOnlyCollection removals) + private void RefuseWriteIntoMovedStore(Durable? durable, IReadOnlyCollection upserts, IReadOnlyCollection removals) { var records = durable?.Snapshot.Records ?? Array.Empty(); - if (records.All(record => record.Variant == null) && upserts.All(staged => staged.Record.Variant == null)) + if (records.All(record => record.Variant == null) && upserts.All(staged => staged.Record.Variant == null) && + !upserts.Any(staged => AttachedRegistry?.HoldsUniqueIndex(staged.Entry.SchemaId) == true)) return; var changed = new HashSet(StringComparer.Ordinal); var versions = records.ToDictionary(record => record.Key, record => durable!.Version(record.Key), StringComparer.Ordinal); @@ -4852,9 +4854,9 @@ namespace GameCult.Caching }, inner => new CultWriteConflictException( Cache == null - ? $"{this} holds a variant and was written by a writer with no CultCache, which cannot resolve variants; nothing was written. " + + ? $"{this} holds a variant or a unique index and was written by a writer with no CultCache, which cannot resolve variants; nothing was written. " + $"Records it would have written or removed: {string.Join(", ", recordKeys.Select(key => $"'{key}'"))}; records that differ from what the writer has read: {string.Join(", ", changedKeys.Select(key => $"'{key}'"))}." - : $"{this} changed since this cache last read it, and it holds a variant, which resolves against other records; nothing was written. " + + : $"{this} changed since this cache last read it, and it holds a variant or a record with a unique index, which resolve against other records; nothing was written. " + $"Records this write would have written or removed: {string.Join(", ", recordKeys.Select(key => $"'{key}'"))}; records that changed: {string.Join(", ", changedKeys.Select(key => $"'{key}'"))}. " + (inner == null ? "The cache reloaded them; retry the write against what the store holds now." diff --git a/src/GameCult.Caching/CultDocumentContracts.cs b/src/GameCult.Caching/CultDocumentContracts.cs index b439ac9f..0f8235ce 100644 --- a/src/GameCult.Caching/CultDocumentContracts.cs +++ b/src/GameCult.Caching/CultDocumentContracts.cs @@ -38,6 +38,7 @@ namespace GameCult.Caching { } + // A unique lookup: within one document type, a non-blank value names one record. Also filterable by CultNet selection. [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true, AllowMultiple = false)] public sealed class CultIndexAttribute : Attribute { @@ -49,6 +50,19 @@ namespace GameCult.Caching public string? Alias { get; } } + // Names the member's selection and reference role (the alias, or the member name). Implies nothing about uniqueness: + // many records may hold one value, and a role member is never indexed for lookup. + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true, AllowMultiple = false)] + public sealed class CultRoleAttribute : Attribute + { + public CultRoleAttribute(string? alias = null) + { + Alias = alias; + } + + public string? Alias { get; } + } + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true)] public sealed class CultReferenceAttribute : Attribute { diff --git a/src/GameCult.Caching/README.md b/src/GameCult.Caching/README.md index fc5cd308..471361dd 100644 --- a/src/GameCult.Caching/README.md +++ b/src/GameCult.Caching/README.md @@ -22,9 +22,12 @@ public sealed class ItemData [Key(1)] [CultIndex] - public string Category = string.Empty; + public string Sku = string.Empty; [Key(2)] + public string Category = string.Empty; + + [Key(3)] public int Value; } ``` @@ -33,7 +36,8 @@ Available cache attributes: - `CultDocument(schemaName, schemaVersion)` - `CultName` -- `CultIndex(alias?)` +- `CultIndex(alias?)`: a unique lookup. Within one document type, a non-blank value names one record; a commit that would leave two records holding one is refused, and a store holding such a pair refuses to load. Uniqueness is judged by a `CultCache` over the single-file store; a cacheless `WriteDocument`/`WriteRecord` of such a type is refused (also when the catalog declares an index alias the call cannot classify), and concurrent writers on a directory store are not covered (`unique-index-directory-store`). A value is culture-invariant, and a number is its canonical decimal (`1.0m` and `1.00m`, `0.0` and `-0.0` are one value). +- `CultRole(alias?)`: a selection and reference role. Many records may share a value; it is never a lookup. - `CultGlobal` - `CultReference(targetType?, many: false)` @@ -53,6 +57,7 @@ var cache = new CultCache(); var handle = await cache.AddAsync(new ItemData { Name = "Potion", + Sku = "POT-1", Category = "Consumable", Value = 50 }); @@ -66,7 +71,7 @@ Name and index lookups are driven from attributes, not manual registration. ```csharp var byName = cache.GetByName("Potion"); -var byIndex = cache.GetByIndex("Category", "Consumable"); +var byIndex = cache.GetByIndex("Sku", "POT-1"); ``` ### Explicit references @@ -130,6 +135,7 @@ var cache = await CultCacheMessagePack.OpenAsync("Data.msgpack"); var handle = await cache.UpsertAsync(new ItemData { Name = "Potion", + Sku = "POT-1", Category = "Consumable", Value = 50 }); diff --git a/src/GameCult.Geometry/CultGeometryDocuments.cs b/src/GameCult.Geometry/CultGeometryDocuments.cs index 169b9ae1..6756d39a 100644 --- a/src/GameCult.Geometry/CultGeometryDocuments.cs +++ b/src/GameCult.Geometry/CultGeometryDocuments.cs @@ -32,7 +32,7 @@ namespace GameCult.Geometry public string DomainId { get; set; } = string.Empty; [Key(1)] - [CultIndex] + [CultRole] public string RootKey { get; set; } = string.Empty; [Key(2)] @@ -164,7 +164,7 @@ namespace GameCult.Geometry public CultRecordRef DomainKey { get; set; } [Key(2)] - [CultIndex] + [CultRole] public string WorkerGroup { get; set; } = string.Empty; [Key(3)] @@ -326,7 +326,7 @@ namespace GameCult.Geometry public CultRecordRef CutKey { get; set; } [Key(2)] - [CultIndex] + [CultRole] public string SelectedCutId { get; set; } = string.Empty; [Key(3)] diff --git a/src/GameCult.Mesh/CultMeshBodyPublications.cs b/src/GameCult.Mesh/CultMeshBodyPublications.cs index 2358c277..86e49365 100644 --- a/src/GameCult.Mesh/CultMeshBodyPublications.cs +++ b/src/GameCult.Mesh/CultMeshBodyPublications.cs @@ -34,7 +34,7 @@ namespace GameCult.Mesh public sealed class CultMeshBodyPublicationDocument { [Key(0), CultName] public string BodyId { get; set; } = string.Empty; - [Key(1), CultIndex] public string ProducerId { get; set; } = string.Empty; + [Key(1), CultRole] public string ProducerId { get; set; } = string.Empty; [Key(2)] public string SchemaId { get; set; } = string.Empty; [Key(3)] public int LayoutVersion { get; set; } [Key(4)] public long ByteSize { get; set; } diff --git a/src/GameCult.Mesh/CultMeshCdn.cs b/src/GameCult.Mesh/CultMeshCdn.cs index 7f47f291..25650f1c 100644 --- a/src/GameCult.Mesh/CultMeshCdn.cs +++ b/src/GameCult.Mesh/CultMeshCdn.cs @@ -142,17 +142,17 @@ namespace GameCult.Mesh /// Artifact kind, such as asset, build, or package. [Key(1)] - [CultIndex] + [CultRole] public string Kind { get; set; } = CultMeshCdnArtifactKinds.Asset; /// Caller-defined artifact version. [Key(2)] - [CultIndex] + [CultRole] public string Version { get; set; } = string.Empty; /// SHA-256 hash of the full materialized artifact. [Key(3)] - [CultIndex] + [CultRole] public string ContentHash { get; set; } = string.Empty; /// Full materialized artifact size. diff --git a/src/GameCult.Mesh/CultMeshEntityPrefabs.cs b/src/GameCult.Mesh/CultMeshEntityPrefabs.cs index afb0b0bf..8d3f26ef 100644 --- a/src/GameCult.Mesh/CultMeshEntityPrefabs.cs +++ b/src/GameCult.Mesh/CultMeshEntityPrefabs.cs @@ -193,12 +193,12 @@ namespace GameCult.Mesh /// Caller-defined package version. [Key(1)] - [CultIndex] + [CultRole] public string Version { get; set; } = string.Empty; /// Tool that authored the package, such as Brokkr Blender. [Key(2)] - [CultIndex] + [CultRole] public string SourceTool { get; set; } = string.Empty; /// Source scene name or path. @@ -207,12 +207,12 @@ namespace GameCult.Mesh /// Source Blender collection name. [Key(4)] - [CultIndex] + [CultRole] public string SourceCollection { get; set; } = string.Empty; /// Stable hash of graph structure and referenced assets. [Key(5)] - [CultIndex] + [CultRole] public string ContentHash { get; set; } = string.Empty; /// Package creation timestamp in round-trip UTC format. diff --git a/src/GameCult.Mesh/CultMeshSimulationFacts.cs b/src/GameCult.Mesh/CultMeshSimulationFacts.cs index 11c7d296..562be44a 100644 --- a/src/GameCult.Mesh/CultMeshSimulationFacts.cs +++ b/src/GameCult.Mesh/CultMeshSimulationFacts.cs @@ -20,14 +20,14 @@ namespace GameCult.Mesh /// Stable fact identifier. /// [Key(0)] - [CultIndex] + [CultRole] public string FactId { get; set; } = string.Empty; /// /// Shard containing the simulation. /// [Key(1)] - [CultIndex] + [CultRole] public string ShardId { get; set; } = string.Empty; /// @@ -40,21 +40,21 @@ namespace GameCult.Mesh /// Simulation frame or tick. /// [Key(3)] - [CultIndex] + [CultRole] public long Frame { get; set; } /// /// Subject the fact is about. /// [Key(4)] - [CultIndex] + [CultRole] public string SubjectId { get; set; } = string.Empty; /// /// Fact kind, such as hit, collision, or ownership. /// [Key(5)] - [CultIndex] + [CultRole] public string ClaimKind { get; set; } = string.Empty; /// diff --git a/src/GameCult.Mesh/docs/public-api.md b/src/GameCult.Mesh/docs/public-api.md index 072e6c73..31737e9c 100644 --- a/src/GameCult.Mesh/docs/public-api.md +++ b/src/GameCult.Mesh/docs/public-api.md @@ -321,15 +321,16 @@ var bySchema = documents.DocumentBySchema("gamecult.aetheria.station_refit.v1"); Typed collections use the same one-call style: ```csharp -var allies = CultMesh.CollectionByIndex( +// A [CultIndex] value is unique within its type, so the handle holds at most one record. +var flagship = CultMesh.CollectionByIndex( node, - "Faction", - "au"); + "CallSign", + "au-1"); -var currentAllies = await allies.LatestAsync(); -using var changes = allies.WatchChanges(change => UpdateRoster(change)); +var currentFlagship = await flagship.LatestAsync(); +using var changes = flagship.WatchChanges(change => UpdateRoster(change)); -var collections = CultMesh.Collections(allies); +var collections = CultMesh.Collections(flagship); var roster = await collections.LatestAsync(); using var rosterChanges = collections.WatchChanges(RenderRoster); ``` diff --git a/src/GameCult.Networking/CultNetDatabase.cs b/src/GameCult.Networking/CultNetDatabase.cs index af561ea5..9db3cd1b 100644 --- a/src/GameCult.Networking/CultNetDatabase.cs +++ b/src/GameCult.Networking/CultNetDatabase.cs @@ -315,6 +315,22 @@ namespace GameCult.Networking public CultNetSelectionCursorKey? CursorKey { get; set; } } + /// + /// The typed changes ( boxed) one cache commit made, in the order the cache admitted them. + /// is opaque and unique among the commits of one database instance. + /// + public sealed class CultNetDatabaseCommit + { + public CultNetDatabaseCommit(string id, IReadOnlyList changes) + { + Id = id; + Changes = changes; + } + + public string Id { get; } + public IReadOnlyList Changes { get; } + } + /// /// A typed domain change emitted by a CultNet database surface. /// @@ -587,6 +603,7 @@ namespace GameCult.Networking private readonly Dictionary<(string SchemaId, string RecordKey), long> _lastWriteSequence = new(); private readonly Subject _changes = new(); + private readonly Subject _commits = new(); private readonly IDisposable _cacheChanges; private readonly IDisposable _cacheJournal; // What a write door of this database knows about the change it is admitting and the cache does not: the wire @@ -1148,6 +1165,17 @@ namespace GameCult.Networking return _changes; } + /// + /// Watches changes as the cache commits that made them: one event per commit, after every change of it was published to + /// . A consumer that forwards changes to a replica forwards a commit as a unit, so the replica + /// can judge the result of the commit and not each record's intermediate state. + /// + public Observable WatchCommits() + { + ThrowIfDisposed(); + return _commits; + } + /// /// Watches one record. /// @@ -1215,6 +1243,7 @@ namespace GameCult.Networking } _changes.Dispose(); + _commits.Dispose(); } // The cache is the one owner of "a change was committed"; the journal is this database's one decision point and its @@ -1231,6 +1260,8 @@ namespace GameCult.Networking } List? failures = null; + // One journal call is one admission, so one commit; its first Sequence names it. + var commit = new CommitGroup(changes.Count == 0 ? "0" : changes[0].Sequence.ToString(System.Globalization.CultureInfo.InvariantCulture)); List? burned = null; List? burnCauses = null; foreach (var change in changes) @@ -1288,7 +1319,10 @@ namespace GameCult.Networking } lock (_logGate) - _stash[change.Sequence] = new Publication(documentType, kind, change.Key, descriptor.SchemaId, shard, change.Document, change.PreviousDocument); + { + _stash[change.Sequence] = new Publication(documentType, kind, change.Key, descriptor.SchemaId, shard, change.Document, change.PreviousDocument, commit); + commit.Unpublished++; + } } // Every change is stashed and will be published; the writer hears of a failed log afterwards, in one exception. @@ -1321,8 +1355,9 @@ namespace GameCult.Networking Publish(publication); } - private void Publish(Publication publication) => - PublishUntyped( + private void Publish(Publication publication) + { + var change = PublishUntyped( publication.DocumentType, publication.Kind, publication.Key, @@ -1330,6 +1365,19 @@ namespace GameCult.Networking publication.Shard, publication.Document, publication.PreviousDocument); + CultNetDatabaseCommit? finished = null; + lock (_logGate) + { + var commit = publication.Commit; + if (change != null) + commit.Published.Add(change); + if (--commit.Unpublished == 0 && commit.Published.Count > 0) + finished = new CultNetDatabaseCommit(commit.Id, commit.Published.ToArray()); + } + + if (finished != null) + _commits.OnNext(finished); + } private void RecordReplicatedEntry( CultNetShardDescriptor shard, @@ -1467,8 +1515,10 @@ namespace GameCult.Networking string schemaId, CultNetShardDescriptor shard, object? document, - object? previousDocument) + object? previousDocument, + CommitGroup commit) { + Commit = commit; DocumentType = documentType; Kind = kind; Key = key; @@ -1485,6 +1535,16 @@ namespace GameCult.Networking public CultNetShardDescriptor Shard { get; } public object? Document { get; } public object? PreviousDocument { get; } + public CommitGroup Commit { get; } + } + + // The changes of one cache admission, counted down as they are published. Guarded by _logGate. + private sealed class CommitGroup + { + public CommitGroup(string id) => Id = id; + public string Id { get; } + public int Unpublished { get; set; } + public List Published { get; } = new(); } private sealed class DoorContext @@ -1964,7 +2024,7 @@ namespace GameCult.Networking return $"{schemaId}:{key.Value}"; } - private void PublishUntyped( + private object? PublishUntyped( Type documentType, CultNetDatabaseChangeKind kind, CultRecordKey key, @@ -1987,6 +2047,8 @@ namespace GameCult.Networking { _changes.OnNext(change); } + + return change; } // No shard owns a schema and key that no descriptor matches, and nothing is written to one: every write path refuses. diff --git a/src/GameCult.Networking/CultNetDatabaseServer.cs b/src/GameCult.Networking/CultNetDatabaseServer.cs index e1694131..32f302fc 100644 --- a/src/GameCult.Networking/CultNetDatabaseServer.cs +++ b/src/GameCult.Networking/CultNetDatabaseServer.cs @@ -765,14 +765,41 @@ namespace GameCult.Networking { // Keys this subscriber only ever received as a refusal: it was never given them as records. var refused = new HashSet(StringComparer.Ordinal); - return _database.WatchAllChanges().Subscribe(change => + return _database.WatchCommits().Subscribe(commit => { - var outbound = DeliverChange(change, subscriptionId, selection, refused, error => peer.SendCultNet(error)); - if (outbound != null) - peer.SendCultNet(outbound); + foreach (var message in DeliverCommit(commit, subscriptionId, selection, refused, error => peer.SendCultNet(error))) + peer.SendCultNet(message); }); } + // One subscriber's view of one commit. Its changes go out together, tagged with the commit, so a replica applies them as one. + internal IReadOnlyList DeliverCommit( + CultNetDatabaseCommit commit, + string subscriptionId, + CultNetSelection selection, + HashSet refused, + Action sendError) + { + var outbound = new List(); + foreach (var change in commit.Changes) + { + var message = DeliverChange(change, subscriptionId, selection, refused, sendError); + if (message != null) + outbound.Add(message); + } + + if (outbound.Count > 1) + { + foreach (var message in outbound) + { + message.CommitId = commit.Id; + message.CommitCount = outbound.Count; + } + } + + return outbound; + } + // One subscriber's view of one change. A variant is not a record CultNet can carry (Q6): the subscriber is told, by key, and // is not later told the variant was removed, because it never held it. internal CultNetDatabaseChangeRawMessage? DeliverChange( diff --git a/src/GameCult.Networking/CultNetDatabaseSubscriptionClient.cs b/src/GameCult.Networking/CultNetDatabaseSubscriptionClient.cs index db1e2907..971a53c2 100644 --- a/src/GameCult.Networking/CultNetDatabaseSubscriptionClient.cs +++ b/src/GameCult.Networking/CultNetDatabaseSubscriptionClient.cs @@ -29,6 +29,14 @@ namespace GameCult.Networking private readonly ConcurrentDictionary _initialSnapshots = new(); private readonly ConcurrentDictionary _deliveryModes = new(StringComparer.Ordinal); + // Changes of a commit that has not fully arrived, by subscription and commit id. + // Guarded by its own lock: the queue fills it, Unsubscribe and Dispose evict. A commit that does not complete is bounded + // by MaxPendingCommits and by MaxBufferedChanges, and a violation faults the client. + private const int MaxPendingCommits = 64; + // Policy: the most changes the incomplete commits may announce between them, the bound on what the buffer can hold. It + // sits far above any commit a source makes; a commit that announces more than this could not be held at all. + private const int MaxBufferedChanges = 1_000_000; + private readonly Dictionary<(string SubscriptionId, string CommitId), PendingCommit> _commits = new(); private readonly object _queueLock = new(); private Task _queue = Task.CompletedTask; private bool _disposed; @@ -48,6 +56,15 @@ namespace GameCult.Networking /// Raised after a live change has been applied to the local typed cache. public event Action? Changed; + /// + /// Raised when the local cache refuses a replicated commit. The replica then differs from its source; the client applies + /// no further changes and reports until it is replaced by a new subscription. + /// + public event Action? Faulted; + + /// The refusal that stopped replication, or null while the replica is intact. + public Exception? Fault { get; private set; } + /// Gets the local reactive cache receiving replicated documents. public CultCache Cache => _cache; @@ -109,6 +126,8 @@ namespace GameCult.Networking CultNetDatabaseSubscriptionDeliveryMode deliveryMode = CultNetDatabaseSubscriptionDeliveryMode.ReplicateToCache) { if (_disposed) throw new ObjectDisposedException(nameof(CultNetDatabaseSubscriptionClient)); + if (Fault != null) + throw new InvalidOperationException("The replica refused a commit and differs from its source; subscribe with a new client.", Fault); if (!_client.Connected) throw new InvalidOperationException("CultNet schema client must be connected before subscribing."); if (string.IsNullOrWhiteSpace(subscriptionId)) throw new ArgumentException("Subscription id is required.", nameof(subscriptionId)); @@ -166,6 +185,9 @@ namespace GameCult.Networking if (_disposed) return; if (string.IsNullOrWhiteSpace(subscriptionId)) throw new ArgumentException("Subscription id is required.", nameof(subscriptionId)); _deliveryModes.TryRemove(subscriptionId, out _); + lock (_commits) + foreach (var held in _commits.Keys.Where(key => key.SubscriptionId == subscriptionId).ToArray()) + _commits.Remove(held); _client.SendCultNet(new CultNetDatabaseUnsubscribeMessage { MessageId = Guid.NewGuid().ToString("N"), @@ -181,6 +203,8 @@ namespace GameCult.Networking pending.Completion.TrySetException(new ObjectDisposedException(nameof(CultNetDatabaseSubscriptionClient))); _initialSnapshots.Clear(); _deliveryModes.Clear(); + lock (_commits) + _commits.Clear(); _client.Dispose(); } @@ -211,48 +235,111 @@ namespace GameCult.Networking private void HandleChange(CultNetDatabaseChangeRawMessage message) { - Enqueue(async () => + Enqueue(() => { var deliveryMode = _deliveryModes.TryGetValue(message.SubscriptionId, out var configured) ? configured : CultNetDatabaseSubscriptionDeliveryMode.ReplicateToCache; - if (string.Equals(message.ChangeKind, "removed", StringComparison.Ordinal)) + // Live delivery applies nothing, so there is no commit to judge; each change is reported as it arrives. + if (deliveryMode != CultNetDatabaseSubscriptionDeliveryMode.ReplicateToCache || message.CommitId == null || !(message.CommitCount > 1)) + return Apply(deliveryMode, new[] { message }); + + // The commit's other changes follow on this connection; the replica applies them as one commit. + // A straggler of a subscription that was ended, or never made, is not buffered. + if (!_deliveryModes.ContainsKey(message.SubscriptionId)) + return Task.CompletedTask; + var held = (message.SubscriptionId, message.CommitId); + PendingCommit? pending; + lock (_commits) { - if (deliveryMode == CultNetDatabaseSubscriptionDeliveryMode.ReplicateToCache) - await DeleteAsync(message).ConfigureAwait(false); - Changed?.Invoke(new CultNetReplicatedDocumentChange( - message.SubscriptionId, - "removed", - message.RecordKey ?? string.Empty, - message.SchemaId ?? string.Empty, - null)); - return; + if (Fault != null) + return Task.CompletedTask; + var count = message.CommitCount!.Value; + var refusal = !_commits.TryGetValue(held, out pending) + ? (_commits.Count >= MaxPendingCommits + ? $"more than {MaxPendingCommits} commits are incomplete at once" + : (long)count + _commits.Sum(entry => (long)entry.Value.Announced) > MaxBufferedChanges + ? $"commit '{message.CommitId}' announces {count} changes; the incomplete commits may announce {MaxBufferedChanges} between them" + : null) + : pending.Announced != count + ? $"commit '{message.CommitId}' announces {pending.Announced} and {count} changes" + : pending.Ids.Contains(message.MessageId) + ? $"commit '{message.CommitId}' delivered message '{message.MessageId}' twice" + : null; + if (refusal != null) + { + Break(new InvalidOperationException($"CultNet subscription '{message.SubscriptionId}': {refusal}.")); + return Task.CompletedTask; + } + + if (!_commits.TryGetValue(held, out pending)) + _commits[held] = pending = new PendingCommit(count); + pending.Ids.Add(message.MessageId); + pending.Messages.Add(message); + if (pending.Messages.Count < count) + return Task.CompletedTask; + _commits.Remove(held); } - if (message.Document == null) return; - var document = deliveryMode == CultNetDatabaseSubscriptionDeliveryMode.Live - ? _documents.DeserializeRawDocument(message.Document) - : await _documents.ApplyRawDocumentPutMessageAsync( - _cache, - new CultNetDocumentPutRawMessage - { - MessageId = message.MessageId, - Document = message.Document - }).ConfigureAwait(false); - Changed?.Invoke(new CultNetReplicatedDocumentChange( - message.SubscriptionId, - message.ChangeKind, - message.Document.RecordKey, - message.Document.SchemaId, - document)); + return Apply(deliveryMode, pending.Messages); }); } - private Task DeleteAsync(CultNetDatabaseChangeRawMessage message) + private sealed class PendingCommit { - if (string.IsNullOrWhiteSpace(message.RecordKey) || string.IsNullOrWhiteSpace(message.SchemaId)) return Task.CompletedTask; - _documents.ResolveDescriptorForSchemaId(message.SchemaId!); - _cache.Remove(new CultRecordKey(message.RecordKey!)); + public PendingCommit(int announced) => Announced = announced; + public int Announced { get; } + public List Messages { get; } = new(); + public HashSet Ids { get; } = new(StringComparer.Ordinal); + } + + // The replica no longer matches its source: say so once, drop what was waiting, and apply nothing more. + private void Break(Exception error) + { + lock (_commits) + { + if (Fault != null) + return; + Fault = error; + _commits.Clear(); + } + + Faulted?.Invoke(error); + } + + private Task Apply(CultNetDatabaseSubscriptionDeliveryMode deliveryMode, IReadOnlyList changes) + { + // A replica that refused a commit no longer matches its source. Applying later commits on top would hide that, so + // nothing more is applied: the fault is surfaced and the replica is rebuilt by subscribing again. + if (Fault != null) + return Task.CompletedTask; + IReadOnlyList applied; + try + { + applied = deliveryMode == CultNetDatabaseSubscriptionDeliveryMode.Live + ? changes.Select(change => change.Document == null ? null : _documents.DeserializeRawDocument(change.Document)).ToArray() + : _documents.ApplyRawChangesAsOneCommit(_cache, changes); + } + catch (Exception error) + { + Break(error); + return Task.CompletedTask; + } + + for (var index = 0; index < changes.Count; index++) + { + var change = changes[index]; + var removed = string.Equals(change.ChangeKind, "removed", StringComparison.Ordinal); + if (!removed && change.Document == null) + continue; + Changed?.Invoke(new CultNetReplicatedDocumentChange( + change.SubscriptionId, + change.ChangeKind, + removed ? change.RecordKey ?? string.Empty : change.Document!.RecordKey, + removed ? change.SchemaId ?? string.Empty : change.Document!.SchemaId, + removed ? null : applied[index])); + } + return Task.CompletedTask; } diff --git a/src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs b/src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs index 8398feba..1325f85e 100644 --- a/src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs +++ b/src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs @@ -300,50 +300,76 @@ namespace GameCult.Networking SubscriptionKey key) { var legacyRequest = request.ToLegacyShape(subscriptionId); - return _database.WatchAllChanges().Subscribe(change => + return _database.WatchCommits().Subscribe(commit => { lock (_lifecycleGate) { if (_disposed || !_requests.ContainsKey(key) || !_projections.TryGetValue(key, out var projection)) return; - if (_authorizeRequest?.Invoke(legacyRequest, peer) == false) - { - Reconcile(key, request); - return; - } - - // D6: cites/cited are set-dependent - a change to row B can change whether row A - // matches - so a hop-bearing selection runs the full diff-against-delivered - // reconcile on every change instead of the single-row fast path. - if (request.Selection.HasHop) - { - Reconcile(key, request); - return; - } - - var sourceRecordKey = ResolveChangeRecordKey(change); - CultNetRawDocumentRecord? matched; + // What this subscription sends for the commit is held and sent together, tagged with the commit, so the replica + // applies it as one commit and judges the result. + var outbox = _outbox = new CommitOutbox(commit.Id); try { - matched = CreateMatchedRecord(change, request, peer); + ForwardCommit(commit, request, key, projection, legacyRequest, peer, subscriptionId); } - catch (NotSupportedException refusal) + finally { - // Q6: a variant is not a record CultNet can carry; the subscriber that would have received it is told, by key. - peer.SendCultNet(CultNetErrorMessage.ForVariantUnsupported(refusal.Message)); - return; + _outbox = null; + outbox.Send(peer); } - - ApplyProjectedChange( - projection, - sourceRecordKey, - matched, - peer, - subscriptionId); } }); } + private void ForwardCommit( + CultNetDatabaseCommit commit, + SubscriptionRequest request, + SubscriptionKey key, + SubscriptionProjectionState projection, + CultNetDatabaseSubscribeMessage legacyRequest, + ICultNetSchemaServerPeer peer, + string subscriptionId) + { + if (_authorizeRequest?.Invoke(legacyRequest, peer) == false) + { + Reconcile(key, request); + return; + } + + // D6: cites/cited are set-dependent - a change to row B can change whether row A + // matches - so a hop-bearing selection runs the full diff-against-delivered + // reconcile once per commit instead of the single-row fast path. + if (request.Selection.HasHop) + { + Reconcile(key, request); + return; + } + + foreach (var change in commit.Changes) + { + var sourceRecordKey = ResolveChangeRecordKey(change); + CultNetRawDocumentRecord? matched; + try + { + matched = CreateMatchedRecord(change, request, peer); + } + catch (NotSupportedException refusal) + { + // Q6: a variant is not a record CultNet can carry; the subscriber that would have received it is told, by key. + peer.SendCultNet(CultNetErrorMessage.ForVariantUnsupported(refusal.Message)); + continue; + } + + ApplyProjectedChange( + projection, + sourceRecordKey, + matched, + peer, + subscriptionId); + } + } + private void Reconcile(SubscriptionKey key, SubscriptionRequest request) { if (!_projections.TryGetValue(key, out var projection)) return; @@ -487,12 +513,15 @@ namespace GameCult.Networking string.Equals(left.SourceAgentId, right.SourceAgentId, StringComparison.Ordinal) && string.Equals(left.SourceRole, right.SourceRole, StringComparison.Ordinal); - private static void SendUpsert( + // The commit being forwarded, under _lifecycleGate: the change messages it produces are held here, not sent one by one. + private CommitOutbox? _outbox; + + private void SendUpsert( ICultNetSchemaServerPeer peer, string subscriptionId, CultNetRawDocumentRecord document, bool added) => - peer.SendCultNet(new CultNetDatabaseChangeRawMessage + Deliver(peer, new CultNetDatabaseChangeRawMessage { MessageId = Guid.NewGuid().ToString("N"), SubscriptionId = subscriptionId, @@ -500,11 +529,11 @@ namespace GameCult.Networking Document = document }); - private static void SendRemoval( + private void SendRemoval( ICultNetSchemaServerPeer peer, string subscriptionId, CultNetRawDocumentRecord document) => - peer.SendCultNet(new CultNetDatabaseChangeRawMessage + Deliver(peer, new CultNetDatabaseChangeRawMessage { MessageId = Guid.NewGuid().ToString("N"), SubscriptionId = subscriptionId, @@ -513,6 +542,38 @@ namespace GameCult.Networking SchemaId = document.SchemaId }); + private void Deliver(ICultNetSchemaServerPeer peer, CultNetDatabaseChangeRawMessage message) + { + if (_outbox is { } outbox) + outbox.Messages.Add(message); + else + peer.SendCultNet(message); + } + + private sealed class CommitOutbox + { + private readonly string _commitId; + + public CommitOutbox(string commitId) => _commitId = commitId; + + public List Messages { get; } = new(); + + public void Send(ICultNetSchemaServerPeer peer) + { + if (Messages.Count > 1) + { + foreach (var message in Messages) + { + message.CommitId = _commitId; + message.CommitCount = Messages.Count; + } + } + + foreach (var message in Messages) + peer.SendCultNet(message); + } + } + private void Withdraw(SubscriptionKey key, bool sendRemovals, bool forgetRequest) { if (_subscriptions.TryRemove(key, out var subscription)) diff --git a/src/GameCult.Networking/CultNetDocumentRegistry.cs b/src/GameCult.Networking/CultNetDocumentRegistry.cs index 58b7ca36..19f17c8c 100644 --- a/src/GameCult.Networking/CultNetDocumentRegistry.cs +++ b/src/GameCult.Networking/CultNetDocumentRegistry.cs @@ -722,6 +722,49 @@ namespace GameCult.Networking return document; } + /// + /// Applies change messages to a cache as one commit, so the cache judges the result of the changes together and not the + /// state between them. Returns the applied document of each message, null for a removal. A refusal by the cache throws + /// and nothing is applied. + /// + internal IReadOnlyList ApplyRawChangesAsOneCommit( + CultCache cache, + IReadOnlyList changes) + { + var applied = new object?[changes.Count]; + var staged = new List<(Type? Type, object? Document, CultRecordKey Key)>(changes.Count); + for (var index = 0; index < changes.Count; index++) + { + var change = changes[index]; + if (string.Equals(change.ChangeKind, "removed", StringComparison.Ordinal)) + { + if (string.IsNullOrWhiteSpace(change.RecordKey) || string.IsNullOrWhiteSpace(change.SchemaId)) + continue; + ResolveDescriptorForSchemaId(change.SchemaId!); + staged.Add((null, null, new CultRecordKey(change.RecordKey!))); + continue; + } + + if (change.Document == null) + continue; + var document = DeserializeRawDocument(change.Document); + applied[index] = document; + staged.Add((ResolveDescriptorForRawDocument(change.Document).DocumentType, document, new CultRecordKey(change.Document.RecordKey))); + } + + cache.Commit(batch => + { + foreach (var (type, document, key) in staged) + { + if (type == null) + batch.Remove(key); + else + batch.Upsert(type, document!, key); + } + }); + return applied; + } + /// /// Decodes one validated raw document without materializing it in a CultCache replica. /// Use this for ephemeral reactive subscriptions whose callback is the consumer. @@ -754,25 +797,22 @@ namespace GameCult.Networking } /// - /// Applies all documents from a raw snapshot response. + /// Applies all documents from a raw snapshot response as one commit, so a snapshot that moves a unique-index value + /// between records is judged on its result. /// - public async Task> ApplyRawSnapshotResponseAsync( + public Task> ApplyRawSnapshotResponseAsync( CultCache cache, CultNetSnapshotResponseRawMessage response) { - var applied = new List(response.Documents.Length); - foreach (var document in response.Documents) - { - applied.Add(await ApplyRawDocumentPutMessageAsync( - cache, - new CultNetDocumentPutRawMessage - { - MessageId = response.MessageId, - Document = document - }).ConfigureAwait(false)); - } - - return applied; + var applied = ApplyRawChangesAsOneCommit( + cache, + response.Documents.Select(document => new CultNetDatabaseChangeRawMessage + { + SubscriptionId = response.MessageId, + ChangeKind = "added", + Document = document + }).ToArray()); + return Task.FromResult>(applied.Select(document => document!).ToArray()); } internal CultDocumentDescriptor ResolveDescriptorForRawDocument(CultNetRawDocumentRecord document) diff --git a/src/GameCult.Networking/CultNetSchemaMessages.cs b/src/GameCult.Networking/CultNetSchemaMessages.cs index a2211672..bc59b007 100644 --- a/src/GameCult.Networking/CultNetSchemaMessages.cs +++ b/src/GameCult.Networking/CultNetSchemaMessages.cs @@ -1179,6 +1179,17 @@ namespace GameCult.Networking /// Gets or sets the deleted schema id for removed changes. /// [Key("schemaId")] public string? SchemaId { get; set; } + /// + /// Gets or sets the identity of the cache commit this change belongs to. Optional and additive: a peer that does not + /// know the field ignores it and applies the change alone. Opaque to the receiver; equal ids on one subscription + /// name changes of one commit. + /// + [Key("commitId")] public string? CommitId { get; set; } + /// + /// Gets or sets how many change messages of this commit this subscription carries. Set with + /// when that is more than one; the receiver applies the commit once all have arrived. + /// + [Key("commitCount")] public int? CommitCount { get; set; } } /// diff --git a/tests/GameCult.Caching.Tests/CultDocumentSelectionSurfaceTests.cs b/tests/GameCult.Caching.Tests/CultDocumentSelectionSurfaceTests.cs index daa71c81..ed77bb90 100644 --- a/tests/GameCult.Caching.Tests/CultDocumentSelectionSurfaceTests.cs +++ b/tests/GameCult.Caching.Tests/CultDocumentSelectionSurfaceTests.cs @@ -226,8 +226,8 @@ namespace GameCult.Caching.Tests public void DuplicateIndexAliasOnTwoDifferentlyNamedMembersIsRefusedByName() { var type = Document("DupAlias"); - Field(type, "First", typeof(string), Key(0), IndexAttr("shared")); - Field(type, "Second", typeof(string), Key(1), IndexAttr("shared")); + Field(type, "First", typeof(string), Key(0), RoleAttr("shared")); + Field(type, "Second", typeof(string), Key(1), RoleAttr("shared")); var message = Assert.Throws(() => CultDocumentRegistry.ForTypes(new[] { type.CreateType()! }))!.Message; Assert.That(message, Does.Contain("DupAlias")); @@ -236,7 +236,7 @@ namespace GameCult.Caching.Tests Assert.That(message, Does.Contain("shared")); } - // R-AL/SM-6: a plain data member's bare name (it declares no [CultIndex], so its role in + // R-AL/SM-6: a plain data member's bare name (it declares no [CultRole], so its role in // ReferencesOf/ReferenceMembers falls back to its own MemberName) colliding with a reference // member's explicit alias. D10 above only groups members that declare an alias and misses // this; before the fix this registered without error and then every hop through the @@ -246,7 +246,7 @@ namespace GameCult.Caching.Tests { var type = Document("OwnerCollision"); Field(type, "Owner", typeof(string), Key(0)); - Field(type, "Ref", typeof(CultRecordRef), Key(1), IndexAttr("Owner")); + Field(type, "Ref", typeof(CultRecordRef), Key(1), RoleAttr("Owner")); var message = Assert.Throws(() => CultDocumentRegistry.ForTypes(new[] { type.CreateType()! }))!.Message; Assert.That(message, Does.Contain("OwnerCollision")); @@ -295,8 +295,8 @@ namespace GameCult.Caching.Tests private static CustomAttributeBuilder Key(int slot) => new(typeof(KeyAttribute).GetConstructor(new[] { typeof(int) })!, new object[] { slot }); - private static CustomAttributeBuilder IndexAttr(string alias) => - new(typeof(CultIndexAttribute).GetConstructor(new[] { typeof(string) })!, new object?[] { alias }); + private static CustomAttributeBuilder RoleAttr(string alias) => + new(typeof(CultRoleAttribute).GetConstructor(new[] { typeof(string) })!, new object?[] { alias }); private static CustomAttributeBuilder ReferenceAttr(Type? targetType, bool many) => new(typeof(CultReferenceAttribute).GetConstructor(new[] { typeof(Type), typeof(bool) })!, new object?[] { targetType, many }); @@ -308,7 +308,7 @@ namespace GameCult.Caching.Tests public string Name = string.Empty; [Key(1)] - [CultIndex("mass")] + [CultRole("mass")] public float Mass; } @@ -325,7 +325,7 @@ namespace GameCult.Caching.Tests public sealed class FixtureLeafB : FixtureMiddle { [Key(2)] - [CultIndex("weight")] + [CultRole("weight")] public decimal? Weight; } @@ -341,19 +341,19 @@ namespace GameCult.Caching.Tests public string Name = string.Empty; [Key(1)] - [CultIndex("big_integer")] + [CultRole("big_integer")] public long BigInteger; [Key(2)] - [CultIndex("trimmed_decimal")] + [CultRole("trimmed_decimal")] public decimal TrimmedDecimal; [Key(3)] - [CultIndex("wide_double")] + [CultRole("wide_double")] public double WideDouble; [Key(4)] - [CultIndex("tiny_double")] + [CultRole("tiny_double")] public double TinyDouble; } diff --git a/tests/GameCult.Caching.Tests/DocumentShapeTests.cs b/tests/GameCult.Caching.Tests/DocumentShapeTests.cs index c34834ca..0d473a5a 100644 --- a/tests/GameCult.Caching.Tests/DocumentShapeTests.cs +++ b/tests/GameCult.Caching.Tests/DocumentShapeTests.cs @@ -394,7 +394,7 @@ namespace GameCult.Caching.Tests public class Gear { [Key(0)] [CultName] public string Name { get; set; } = ""; - [Key(1)] [CultIndex("slot")] public string Slot { get; set; } = ""; + [Key(1)] [CultRole("slot")] public string Slot { get; set; } = ""; } [CultDocument("shape.weapon", "shape.weapon.v1")] diff --git a/tests/GameCult.Caching.Tests/EmittedDocumentTypes.cs b/tests/GameCult.Caching.Tests/EmittedDocumentTypes.cs index ac6a6dad..5ca50c69 100644 --- a/tests/GameCult.Caching.Tests/EmittedDocumentTypes.cs +++ b/tests/GameCult.Caching.Tests/EmittedDocumentTypes.cs @@ -12,7 +12,7 @@ namespace GameCult.Caching.Tests // two ways), an alias whose resolved type would depend on scan order, or a type a test needs to itself. internal static class EmittedDocumentTypes { - internal readonly record struct Field(string Name, Type Type, int Key, bool IsName = false); + internal readonly record struct Field(string Name, Type Type, int Key, bool IsName = false, string? Index = null, string? Role = null); internal static Type Emit(string typeName, string schemaName, string schemaVersion, Field[]? fields = null, string[]? compatibleSchemaIds = null) { @@ -37,6 +37,10 @@ namespace GameCult.Caching.Tests builder.SetCustomAttribute(new CustomAttributeBuilder(typeof(KeyAttribute).GetConstructor(new[] { typeof(int) })!, new object[] { field.Key })); if (field.IsName) builder.SetCustomAttribute(new CustomAttributeBuilder(typeof(CultNameAttribute).GetConstructor(Type.EmptyTypes)!, Array.Empty())); + if (field.Index != null) + builder.SetCustomAttribute(new CustomAttributeBuilder(typeof(CultIndexAttribute).GetConstructor(new[] { typeof(string) })!, new object[] { field.Index })); + if (field.Role != null) + builder.SetCustomAttribute(new CustomAttributeBuilder(typeof(CultRoleAttribute).GetConstructor(new[] { typeof(string) })!, new object[] { field.Role })); } return type.CreateType()!; diff --git a/tests/GameCult.Caching.Tests/FirstCallTests.cs b/tests/GameCult.Caching.Tests/FirstCallTests.cs index 8540d9d0..8fad1229 100644 --- a/tests/GameCult.Caching.Tests/FirstCallTests.cs +++ b/tests/GameCult.Caching.Tests/FirstCallTests.cs @@ -14,7 +14,7 @@ using MessagePack; public sealed class Item { [Key(0)] [CultName] public string Name = string.Empty; - [Key(1)] [CultIndex] public string Category = string.Empty; + [Key(1)] public string Category = string.Empty; [Key(2)] public int Value; } diff --git a/tests/GameCult.Caching.Tests/UniqueIndexTests.cs b/tests/GameCult.Caching.Tests/UniqueIndexTests.cs new file mode 100644 index 00000000..45817296 --- /dev/null +++ b/tests/GameCult.Caching.Tests/UniqueIndexTests.cs @@ -0,0 +1,459 @@ +#nullable enable +using System; +using System.IO; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Caching.Tests +{ + // variants:ruling:unique-indexes. Within one document type and one [CultIndex] alias, no two held records hold one + // non-blank value; CultCache.Resolve judges every commit and every load, and a refusal never echoes the value. + public class UniqueIndexTests + { + private const string Canary = "CANARY-7f3a"; + + private static readonly CultDocumentRegistry Registry = CultDocumentRegistry.ForTypes(new[] + { + typeof(UqItem), typeof(UqTwin), typeof(UqTagged), typeof(UqDecimal), typeof(UqDouble), typeof(UqStamp) + }); + + // Emitted, so CultDocumentRegistry.Shared never scans them (one schema declared two ways, and a member Shared would refuse). + // The same schema name, version and shape, once as a role and once as a unique index: a role store holds the pair, the index type refuses it. + private static readonly Type UqLoadRole = EmittedDocumentTypes.Emit( + "UqLoadRole", "tests.uq_load", "tests.uq_load.v1", new[] { new EmittedDocumentTypes.Field("Code", typeof(string), 0, Role: "code") }); + private static readonly Type UqLoadIndex = EmittedDocumentTypes.Emit( + "UqLoadIndex", "tests.uq_load", "tests.uq_load.v1", new[] { new EmittedDocumentTypes.Field("Code", typeof(string), 0, Index: "code") }); + private static readonly Type UqBoth = EmittedDocumentTypes.Emit( + "UqBoth", "tests.uq_both", "tests.uq_both.v1", new[] { new EmittedDocumentTypes.Field("Both", typeof(string), 0, Index: "x", Role: "x") }); + + private static object Load(Type type, string code) => EmittedDocumentTypes.New(type, ("Code", code)); + + private string _directory = string.Empty; + + [SetUp] + public void CreateDirectory() + { + _directory = Path.Combine(Path.GetTempPath(), $"cultlib-uidx-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_directory); + } + + [TearDown] + public void DeleteDirectory() + { + if (Directory.Exists(_directory)) + Directory.Delete(_directory, recursive: true); + } + + private string PathOf(string name) => Path.Combine(_directory, name); + + private static CultCache Open(string path, CultDocumentRegistry? registry = null) => + CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = registry ?? Registry }); + + private static CultRecordKey Key(string name) => new(name); + + private static UqItem Item(string? sku) => new() { Sku = sku }; + + private static InvalidOperationException Refused(TestDelegate write) => Assert.Throws(write)!; + + [Test] + public void ABatchLeavingTwoRecordsWithOneValueIsRefusedNamingBothKeysAndTheIndex() + { + var path = PathOf("items.cc"); + using var cache = Open(path); + cache.Commit(batch => batch.Upsert(typeof(UqItem), Item("keep"), Key("seed"))); + var before = File.ReadAllBytes(path); + + var refused = Refused(() => cache.Commit(batch => + { + batch.Upsert(typeof(UqItem), Item(Canary), Key("zeta")); + batch.Upsert(typeof(UqItem), Item(Canary), Key("alpha")); + })); + + Assert.That(refused.Message, Does.Contain("alpha").And.Contain("zeta").And.Contain("sku").And.Contain("tests.uq_item")); + Assert.That(refused.Message.IndexOf("alpha", StringComparison.Ordinal), Is.LessThan(refused.Message.IndexOf("zeta", StringComparison.Ordinal)), "keys in ordinal order"); + Assert.That(refused.Message, Does.Not.Contain(Canary), "the value is never echoed"); + Assert.That(cache.Get(Key("alpha")), Is.Null); + Assert.That(cache.Get(Key("zeta")), Is.Null); + Assert.That(File.ReadAllBytes(path), Is.EqualTo(before), "the store is byte-identical"); + Assert.That(cache.GetByIndex("sku", Canary), Is.Null); + } + + [Test] + public async Task AWriteOfAHeldValueAtAnotherKeyIsRefused() + { + using var cache = Open(PathOf("items.cc")); + await cache.UpsertAsync(typeof(UqItem), Item(Canary), Key("first")); + + var single = Assert.ThrowsAsync(() => cache.UpsertAsync(typeof(UqItem), Item(Canary), Key("second")))!; + Assert.That(single.Message, Does.Contain("first").And.Contain("second").And.Contain("sku").And.Not.Contain(Canary)); + var batched = Refused(() => cache.Commit(batch => batch.Upsert(typeof(UqItem), Item(Canary), Key("third")))); + Assert.That(batched.Message, Does.Contain("first").And.Contain("third")); + + Assert.That(cache.Get(Key("second")), Is.Null); + Assert.That(cache.Get(Key("third")), Is.Null); + Assert.That(cache.GetByIndex("sku", Canary), Is.Not.Null); + + await cache.UpsertAsync(typeof(UqItem), Item(Canary), Key("first")); + } + + [Test] + public void SwappingTwoValuesInOneCommitLandsAndBothAreFound() + { + var path = PathOf("items.cc"); + using (var cache = Open(path)) + { + cache.Commit(batch => + { + batch.Upsert(typeof(UqItem), Item("one"), Key("a")); + batch.Upsert(typeof(UqItem), Item("two"), Key("b")); + }); + cache.Commit(batch => + { + batch.Upsert(typeof(UqItem), Item("two"), Key("a")); + batch.Upsert(typeof(UqItem), Item("one"), Key("b")); + }); + + Assert.That(cache.GetStored(Key("a"))!.Document, Is.SameAs(cache.GetByIndex("sku", "two"))); + Assert.That(cache.GetStored(Key("b"))!.Document, Is.SameAs(cache.GetByIndex("sku", "one"))); + } + + using var reopened = Open(path); + Assert.That(reopened.GetStored(Key("a"))!.Document, Is.SameAs(reopened.GetByIndex("sku", "two"))); + Assert.That(reopened.GetStored(Key("b"))!.Document, Is.SameAs(reopened.GetByIndex("sku", "one"))); + } + + [Test] + public void AValueMovesWhenItsHolderIsRemovedOrChangedInTheSameCommit() + { + using var cache = Open(PathOf("items.cc")); + cache.Commit(batch => + { + batch.Upsert(typeof(UqItem), Item("one"), Key("a")); + batch.Upsert(typeof(UqItem), Item("two"), Key("b")); + }); + + cache.Commit(batch => + { + batch.Remove(Key("a")); + batch.Upsert(typeof(UqItem), Item("one"), Key("c")); + }); + Assert.That(cache.GetStored(Key("c"))!.Document, Is.SameAs(cache.GetByIndex("sku", "one")), "removed holder released its value"); + + cache.Commit(batch => + { + batch.Upsert(typeof(UqItem), Item("three"), Key("b")); + batch.Upsert(typeof(UqItem), Item("two"), Key("d")); + }); + Assert.That(cache.GetStored(Key("d"))!.Document, Is.SameAs(cache.GetByIndex("sku", "two")), "changed holder released its value"); + Assert.That(cache.GetStored(Key("b"))!.Document, Is.SameAs(cache.GetByIndex("sku", "three"))); + Assert.That(cache.GetByIndex("sku", "one")!.Sku, Is.EqualTo("one")); + } + + [Test] + public void BlankValuesNeverConflict() + { + var path = PathOf("items.cc"); + using (var cache = Open(path)) + { + cache.Commit(batch => + { + batch.Upsert(typeof(UqItem), Item(null), Key("a")); + batch.Upsert(typeof(UqItem), Item(""), Key("b")); + batch.Upsert(typeof(UqItem), Item(" "), Key("c")); + batch.Upsert(typeof(UqItem), Item(null), Key("d")); + }); + Assert.That(cache.GetByIndex("sku", ""), Is.Null); + Assert.That(cache.GetByIndex("sku", " "), Is.Null); + } + + using var reopened = Open(path); + Assert.That(reopened.Get(Key("d")), Is.Not.Null, "a store of blank values reopens"); + } + + [Test] + public void TwoTypesMayShareAnAliasAndAValue() + { + using var cache = Open(PathOf("items.cc")); + cache.Commit(batch => + { + batch.Upsert(typeof(UqItem), Item("same"), Key("item")); + batch.Upsert(typeof(UqTwin), new UqTwin { Sku = "same" }, Key("twin")); + }); + + Assert.That(cache.GetByIndex("sku", "same"), Is.Not.Null); + Assert.That(cache.GetByIndex("sku", "same"), Is.Not.Null); + Assert.That(Registry.GetRequired().DeclaredMembers.Single().IndexAlias, Is.EqualTo("sku"), "a unique index addresses selection as a role does"); + } + + [Test] + public void RoleMembersMayShareValuesAndStaySelectable() + { + var path = PathOf("tagged.cc"); + using (var cache = Open(path)) + { + cache.Commit(batch => + { + batch.Upsert(typeof(UqTagged), new UqTagged { Kind = "weapon" }, Key("a")); + batch.Upsert(typeof(UqTagged), new UqTagged { Kind = "weapon" }, Key("b")); + }); + Assert.That(cache.GetByIndex("kind", "weapon"), Is.Null, "a role is not a lookup"); + } + + using var reopened = Open(path); + Assert.That(reopened.Get(Key("b")), Is.Not.Null, "a role pair reopens"); + var member = Registry.GetRequired().DeclaredMembers.Single(candidate => candidate.MemberName == nameof(UqTagged.Kind)); + Assert.That(member.IndexAlias, Is.EqualTo("kind"), "the role still supplies the selection address"); + } + + [Test] + public void AStoreHoldingDuplicatesRefusesToOpenNamingBothKeysAndTheIndex() + { + var path = PathOf("load.cc"); + var roleRegistry = CultDocumentRegistry.ForTypes(new[] { UqLoadRole }); + using (var writer = Open(path, roleRegistry)) + { + writer.Commit(batch => + { + batch.Upsert(UqLoadRole, Load(UqLoadRole, Canary), Key("m")); + batch.Upsert(UqLoadRole, Load(UqLoadRole, Canary), Key("k")); + }); + } + + var before = File.ReadAllBytes(path); + var indexRegistry = CultDocumentRegistry.ForTypes(new[] { UqLoadIndex }); + var refused = Refused(() => Open(path, indexRegistry).Dispose()); + + Assert.That(refused.Message, Does.Contain("k").And.Contain("m").And.Contain("code").And.Not.Contain(Canary)); + Assert.That(File.ReadAllBytes(path), Is.EqualTo(before), "the file is untouched"); + + using var cache = new CultCache(indexRegistry); + var store = new SingleFileMessagePackBackingStore(path); + Refused(() => cache.AddBackingStore(store)); + Assert.That(cache.BackingStores, Is.Empty, "the refused store is not attached"); + Assert.That(cache.Get(Key("k")), Is.Null); + } + + [Test] + public void AWriteOntoAMovedFileIsAConflictAndTheRetryIsRefused() + { + var path = PathOf("items.cc"); + using var seed = Open(path); + seed.Commit(batch => batch.Upsert(typeof(UqItem), Item("base"), Key("a"))); + using var second = Open(path); + using (var first = Open(path)) + first.Commit(batch => batch.Upsert(typeof(UqItem), Item(Canary), Key("b"))); + + var conflict = Assert.Throws( + () => second.Commit(batch => batch.Upsert(typeof(UqItem), Item(Canary), Key("c"))))!; + Assert.That(conflict.Message, Does.Contain("unique index")); + Assert.That(second.Get(Key("b")), Is.Not.Null, "the moved record is loaded"); + + var retry = Refused(() => second.Commit(batch => batch.Upsert(typeof(UqItem), Item(Canary), Key("c")))); + Assert.That(retry.Message, Does.Contain("b").And.Contain("c").And.Not.Contain(Canary)); + + using var reopened = Open(path); + Assert.That(reopened.Get(Key("c")), Is.Null, "the file never holds the pair"); + Assert.That(reopened.Get(Key("b")), Is.Not.Null); + } + + [Test] + public void CachelessWritesOfAUniqueIndexedTypeAreRefused() + { + var path = PathOf("items.cc"); + using (var cache = Open(path)) + cache.Commit(batch => batch.Upsert(typeof(UqItem), Item("a"), Key("a"))); + var before = File.ReadAllBytes(path); + var store = new SingleFileMessagePackBackingStore(path); + + Refused(() => store.WriteDocument(Key("x"), Item(Canary), registry: Registry)); + var entry = Registry.GetRequired().ToCatalogEntry(); + Refused(() => store.WriteRecord( + new CultPersistedRecord { Key = "y", SchemaId = entry.SchemaId, StoredAt = DateTimeOffset.UtcNow.ToString("O"), Payload = new byte[] { 0x90 } }, + entry, + registry: Registry)); + + Refused(() => store.WriteRecord( + new CultPersistedRecord { Key = "y", SchemaId = entry.SchemaId, StoredAt = DateTimeOffset.UtcNow.ToString("O"), Payload = new byte[] { 0x90 } }, + entry)); + Refused(() => store.WriteRecord( + new CultPersistedRecord { Key = "y", SchemaId = entry.SchemaId, StoredAt = DateTimeOffset.UtcNow.ToString("O"), Payload = new byte[] { 0x90 } }, + entry, + registry: CultDocumentRegistry.ForTypes(new[] { typeof(UqTagged) }))); + Assert.That(File.ReadAllBytes(path), Is.EqualTo(before), "the file is byte-identical, whichever registry (none, another) was lent"); + store.WriteDocument(Key("t"), new UqTagged { Kind = "k" }, registry: Registry); + using var reopened = Open(path); + Assert.That(reopened.Get(Key("t")), Is.Not.Null, "a role-only type still writes cacheless"); + } + + + // The rule is a function of the whole commit: it must not stop at a prefix, a mixed batch or a small load. + [TestCase(0, 1)] + [TestCase(0, 6)] + [TestCase(2, 5)] + [TestCase(5, 6)] + public void ADuplicateAnywhereInALargeMixedBatchIsRefused(int first, int second) + { + using var cache = Open(PathOf("items.cc")); + + var refused = Refused(() => cache.Commit(batch => + { + batch.Upsert(typeof(UqTagged), new UqTagged { Kind = "same" }, Key("tag-a")); + for (var i = 0; i < 7; i++) + batch.Upsert(typeof(UqItem), Item(i == first || i == second ? "dup" : "v" + i), Key("item-" + i)); + batch.Upsert(typeof(UqTagged), new UqTagged { Kind = "same" }, Key("tag-b")); + })); + + Assert.That(refused.Message, Does.Contain("item-" + first).And.Contain("item-" + second).And.Not.Contain("dup")); + Assert.That(cache.Get(Key("tag-a")), Is.Null, "nothing landed"); + } + + [Test] + public void ADuplicateBesideARecordOfAnotherTypeSharingTheAliasIsStillRefused() + { + using var cache = Open(PathOf("items.cc")); + var refused = Refused(() => cache.Commit(batch => + { + batch.Upsert(typeof(UqTwin), new UqTwin { Sku = "same" }, Key("twin")); + batch.Upsert(typeof(UqItem), Item("same"), Key("item-1")); + batch.Upsert(typeof(UqItem), Item("same"), Key("item-2")); + })); + Assert.That(refused.Message, Does.Contain("item-1").And.Contain("item-2").And.Not.Contain("twin")); + } + + [Test] + public void ValuesDifferingOnlyByCaseAreDifferentValues() + { + using var cache = Open(PathOf("items.cc")); + cache.Commit(batch => + { + batch.Upsert(typeof(UqItem), Item("Sku"), Key("a")); + batch.Upsert(typeof(UqItem), Item("sku"), Key("b")); + }); + Assert.That(cache.GetStored(Key("a"))!.Document, Is.SameAs(cache.GetByIndex("sku", "Sku"))); + Assert.That(cache.GetStored(Key("b"))!.Document, Is.SameAs(cache.GetByIndex("sku", "sku"))); + } + + [Test] + public void ALoadOfManyRecordsRefusesAPairWhereverItSits([Values(0, 3, 6)] int pairAt) + { + var path = PathOf("load.cc"); + var roleRegistry = CultDocumentRegistry.ForTypes(new[] { UqLoadRole }); + using (var writer = Open(path, roleRegistry)) + { + writer.Commit(batch => + { + for (var i = 0; i < 8; i++) + batch.Upsert(UqLoadRole, Load(UqLoadRole, i == pairAt || i == pairAt + 1 ? "dup" : "v" + i), Key("rec-" + i)); + }); + } + + var refused = Refused(() => Open(path, CultDocumentRegistry.ForTypes(new[] { UqLoadIndex })).Dispose()); + Assert.That(refused.Message, Does.Contain("rec-" + pairAt).And.Contain("rec-" + (pairAt + 1)).And.Not.Contain("dup")); + } + + [Test] + public void AMixedBatchOntoAMovedFileIsAConflictWhateverItsOrder() + { + var path = PathOf("items.cc"); + using var seed = Open(path); + seed.Commit(batch => batch.Upsert(typeof(UqItem), Item("base"), Key("a"))); + using var second = Open(path); + using (var first = Open(path)) + first.Commit(batch => batch.Upsert(typeof(UqItem), Item("moved"), Key("b"))); + + Assert.Throws(() => second.Commit(batch => + { + batch.Upsert(typeof(UqTagged), new UqTagged { Kind = "k" }, Key("tag")); + batch.Upsert(typeof(UqItem), Item("fresh"), Key("c")); + })); + } + + [Test] + public void ANumericIndexValueIsOneCanonicalValueInEveryCulture() + { + var culture = (CultureInfo)CultureInfo.InvariantCulture.Clone(); + culture.NumberFormat.NumberDecimalSeparator = ","; + var previous = CultureInfo.CurrentCulture; + CultureInfo.CurrentCulture = culture; + try + { + using var cache = Open(PathOf("numbers.cc")); + cache.Commit(batch => batch.Upsert(typeof(UqDecimal), new UqDecimal { Price = 1.0m }, Key("d1"))); + Assert.That(cache.GetByIndex("price", "1"), Is.Not.Null, "1.0m is the canonical value 1"); + var sameDecimal = Refused(() => cache.Commit(batch => batch.Upsert(typeof(UqDecimal), new UqDecimal { Price = 1.00m }, Key("d2")))); + Assert.That(sameDecimal.Message, Does.Contain("d1").And.Contain("d2")); + + cache.Commit(batch => batch.Upsert(typeof(UqDecimal), new UqDecimal { Price = 1.5m }, Key("d3"))); + Assert.That(cache.GetByIndex("price", "1.5"), Is.Not.Null, "lookups name the invariant spelling, not the current culture's"); + Assert.That(cache.GetByIndex("price", "1,5"), Is.Null); + + culture.DateTimeFormat.ShortDatePattern = "yyyy.dd.MM"; + culture.DateTimeFormat.DateSeparator = "."; + cache.Commit(batch => batch.Upsert(typeof(UqStamp), new UqStamp { When = new DateTime(2026, 1, 2, 3, 4, 5) }, Key("t1"))); + Assert.That(cache.GetByIndex("when", "01/02/2026 03:04:05"), Is.Not.Null, "a date is its invariant spelling"); + + cache.Commit(batch => batch.Upsert(typeof(UqDouble), new UqDouble { Level = 0.0 }, Key("z1"))); + var signedZero = Refused(() => cache.Commit(batch => batch.Upsert(typeof(UqDouble), new UqDouble { Level = -0.0 }, Key("z2")))); + Assert.That(signedZero.Message, Does.Contain("z1").And.Contain("z2")); + } + finally + { + CultureInfo.CurrentCulture = previous; + } + } + + [Test] + public void AMemberWithBothAttributesIsRefusedAtRegistration() + { + var refused = Refused(() => CultDocumentRegistry.ForTypes(new[] { UqBoth })); + Assert.That(refused.Message, Does.Contain("Both").And.Contain("[CultIndex]").And.Contain("[CultRole]")); + } + + [CultDocument("tests.uq_item", "tests.uq_item.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class UqItem + { + [Key(0)] [CultIndex("sku")] public string? Sku { get; set; } + } + + [CultDocument("tests.uq_twin", "tests.uq_twin.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class UqTwin + { + [Key(0)] [CultIndex("sku")] public string? Sku { get; set; } + } + + [CultDocument("tests.uq_decimal", "tests.uq_decimal.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class UqDecimal + { + [Key(0)] [CultIndex("price")] public decimal Price { get; set; } + } + + [CultDocument("tests.uq_stamp", "tests.uq_stamp.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class UqStamp + { + [Key(0)] [CultIndex("when")] public DateTime When { get; set; } + } + + [CultDocument("tests.uq_double", "tests.uq_double.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class UqDouble + { + [Key(0)] [CultIndex("level")] public double Level { get; set; } + } + + [CultDocument("tests.uq_tagged", "tests.uq_tagged.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class UqTagged + { + [Key(0)] [CultRole("kind")] public string? Kind { get; set; } + } + } +} diff --git a/tests/GameCult.Caching.Tests/VariantTests.cs b/tests/GameCult.Caching.Tests/VariantTests.cs index 8f574cd1..17f9ba26 100644 --- a/tests/GameCult.Caching.Tests/VariantTests.cs +++ b/tests/GameCult.Caching.Tests/VariantTests.cs @@ -580,7 +580,7 @@ namespace GameCult.Caching.Tests { cache.Override(nameof(VariantGear.Name), "laser big") }))); - Assert.That(inherited.Message, Does.Contain(BigKey.Value).And.Contain(BaseKey.Value).And.Contain("code").And.Contain("l1")); + Assert.That(inherited.Message, Does.Contain(BigKey.Value).And.Contain(BaseKey.Value).And.Contain("code").And.Not.Contain("l1"), "the value is not echoed"); var explicitly = Refused(() => cache.Commit(batch => batch.UpsertVariant(BigKey, BaseKey, new[] { @@ -631,7 +631,7 @@ namespace GameCult.Caching.Tests SeedBig(cache); var refused = Refused(() => cache.Commit(batch => batch.Upsert(typeof(VariantGear), Other("laser big"), other))); - Assert.That(refused.Message, Does.Contain(other.Value).And.Contain(BigKey.Value).And.Contain("code").And.Contain("laser big")); + Assert.That(refused.Message, Does.Contain(other.Value).And.Contain(BigKey.Value).And.Contain("code").And.Not.Contain("laser big")); var single = Assert.ThrowsAsync( () => cache.UpsertAsync(Other("laser big"), new CultRecordHandle(other)))!; @@ -660,7 +660,7 @@ namespace GameCult.Caching.Tests cache.Commit(batch => batch.Upsert(typeof(VariantGear), Other("laser big"), other)); var refused = Refused(() => SeedBig(cache)); - Assert.That(refused.Message, Does.Contain(BigKey.Value).And.Contain(other.Value).And.Contain("code").And.Contain("laser big")); + Assert.That(refused.Message, Does.Contain(BigKey.Value).And.Contain(other.Value).And.Contain("code").And.Not.Contain("laser big")); Assert.That(cache.Get(BigKey), Is.Null, "nothing landed"); // The same batch, either staging order: the result is what is judged. @@ -685,8 +685,9 @@ namespace GameCult.Caching.Tests })); Assert.That(sameBatchVariantFirst.Message, Does.Contain(BigKey.Value).And.Contain("taker")); - // Plain-vs-plain sharing is not this rule's business. - Assert.DoesNotThrow(() => cache.Commit(batch => batch.Upsert(typeof(VariantGear), Other("l1"), new CultRecordKey("twin")))); + // Plain-vs-plain sharing is the same rule. + var plain = Refused(() => cache.Commit(batch => batch.Upsert(typeof(VariantGear), Other("l1"), new CultRecordKey("twin")))); + Assert.That(plain.Message, Does.Contain("twin").And.Contain(BaseKey.Value)); } using var reopened = Open(path); @@ -819,7 +820,7 @@ namespace GameCult.Caching.Tests using var a = Open(path); using (var b = Open(path)) b.Commit(batch => batch.Upsert(typeof(VariantGear), Other("zc"), z)); - a.Commit(batch => batch.Upsert(typeof(VariantGear), Other("yc"), new CultRecordKey("y"))); + a.Commit(batch => batch.Upsert(typeof(VariantOther), new VariantOther { Name = "yc" }, new CultRecordKey("y"))); var conflict = Assert.Throws(() => SeedBig(a))!; @@ -1100,22 +1101,8 @@ namespace GameCult.Caching.Tests Assert.That(reloaded.Get(BigKey)!.Tags, Is.EqualTo(new[] { "beam", "pierce" })); } - // ---- the held index is truthful: every holder of a value, so no rule judges a variant against a lossy view ---- - private static VariantGear Named(string name, string code) => new() { Name = name, Power = 1, Code = code }; - private static CultRecordKey[] SharedCodePair(CultCache cache) - { - var p1 = new CultRecordKey("p1"); - var p2 = new CultRecordKey("p2"); - cache.Commit(batch => - { - batch.Upsert(typeof(VariantGear), Named("p1", "x"), p1); - batch.Upsert(typeof(VariantGear), Named("p2", "x"), p2); - }); - return new[] { p1, p2 }; - } - private static void VariantOnCode(CultCache cache, string code) => cache.Commit(batch => batch.UpsertVariant(BigKey, BaseKey, new[] { @@ -1123,73 +1110,6 @@ namespace GameCult.Caching.Tests cache.Override(nameof(VariantGear.Code), code) })); - [Test] - public void RemovingTheHolderADuplicateValueFindsLeavesTheOtherFindable() - { - var path = PathOf("gear.cc"); - using (var cache = Open(path)) - { - var pair = SharedCodePair(cache); - Assert.That(cache.GetByIndex("code", "x")!.Name, Is.EqualTo("p1"), "the smallest key"); - - cache.Remove(pair[0]); - Assert.That(cache.GetByIndex("code", "x")!.Name, Is.EqualTo("p2"), "removing the holder finds the other one"); - - cache.Commit(batch => batch.Upsert(typeof(VariantGear), Named("p1", "x"), pair[0])); - Assert.That(cache.GetByIndex("code", "x")!.Name, Is.EqualTo("p1")); - cache.Remove(pair[1]); - Assert.That(cache.GetByIndex("code", "x")!.Name, Is.EqualTo("p1"), "removing the other changes nothing"); - cache.Remove(pair[0]); - Assert.That(cache.GetByIndex("code", "x"), Is.Null); - cache.BackingStores[0].PushAll(); - } - - using var reopened = Open(path); - Assert.That(reopened.GetByIndex("code", "x"), Is.Null); - } - - [Test] - public void AVariantOnAValueAPlainRecordStillHoldsAfterItsTwinWasRemovedIsRefusedAndTheStoreReopens() - { - var path = PathOf("gear.cc"); - using (var cache = Open(path)) - { - SeedBase(cache); - var pair = SharedCodePair(cache); - cache.Remove(pair[1]); - - var refused = Refused(() => VariantOnCode(cache, "x")); - Assert.That(refused.Message, Does.Contain(BigKey.Value).And.Contain("p1").And.Contain("code").And.Contain("'x'")); - Assert.That(cache.Get(BigKey), Is.Null, "nothing landed"); - cache.BackingStores[0].PushAll(); - } - - using var reopened = Open(path); - Assert.That(reopened.Get(BigKey), Is.Null); - Assert.That(reopened.GetByIndex("code", "x")!.Name, Is.EqualTo("p1")); - } - - [Test] - public void AVariantOnAValueAPlainRecordStillHoldsAfterItsTwinChangedItsValueIsRefusedAndTheStoreReopens() - { - var path = PathOf("gear.cc"); - using (var cache = Open(path)) - { - SeedBase(cache); - var pair = SharedCodePair(cache); - cache.Commit(batch => batch.Upsert(typeof(VariantGear), Named("p2", "y"), pair[1])); - - var refused = Refused(() => VariantOnCode(cache, "x")); - Assert.That(refused.Message, Does.Contain(BigKey.Value).And.Contain("p1").And.Contain("'x'")); - Assert.DoesNotThrow(() => VariantOnCode(cache, "z"), "an unshared value is fine"); - cache.BackingStores[0].PushAll(); - } - - using var reopened = Open(path); - Assert.That(reopened.GetByIndex("code", "z")!.Name, Is.EqualTo("laser big")); - Assert.That(reopened.GetByIndex("code", "x")!.Name, Is.EqualTo("p1")); - } - // ---- a merge onto the file is judged on the set the file will hold ---- [Test] @@ -1218,7 +1138,7 @@ namespace GameCult.Caching.Tests var conflict = Assert.Throws(Write)!; Assert.That(conflict.ChangedKeys, Does.Contain(BigKey.Value)); var refused = Refused(Write); - Assert.That(refused.Message, Does.Contain(other.Value).And.Contain(BigKey.Value).And.Contain("code").And.Contain("shared"), "the retry is judged by Resolve"); + Assert.That(refused.Message, Does.Contain(other.Value).And.Contain(BigKey.Value).And.Contain("code").And.Not.Contain("shared"), "the retry is judged by Resolve"); Assert.That(b.Get(other), Is.Null, "nothing landed in memory"); } @@ -1408,24 +1328,24 @@ namespace GameCult.Caching.Tests { var path = PathOf("gear.cc"); var other = new CultRecordKey("other"); - var narrow = CultDocumentRegistry.ForTypes(new[] { typeof(VariantGear) }); + var narrow = CultDocumentRegistry.ForTypes(new[] { typeof(VariantOther) }); using (var seed = Open(path)) SeedBase(seed); using (var a = Open(path)) using (var b = Open(path, registry: narrow)) { - a.Commit(batch => batch.Upsert(typeof(VariantOther), new VariantOther { Name = "foreign" }, new CultRecordKey("foreign"))); + a.Commit(batch => batch.Upsert(typeof(VariantGear), Named("foreign", "foreign-code"), new CultRecordKey("foreign"))); Assert.That(b.Commit(batch => { batch.Expect(other, null); - batch.Upsert(typeof(VariantGear), Named("other", "fine"), other); + batch.Upsert(typeof(VariantOther), new VariantOther { Name = "fine" }, other); }), Is.True); } using var reopened = Open(path); - Assert.That(reopened.Get(other), Is.Not.Null); - Assert.That(reopened.Get(new CultRecordKey("foreign")), Is.Not.Null, "the other writer's record survives the merge"); + Assert.That(reopened.Get(other), Is.Not.Null); + Assert.That(reopened.Get(new CultRecordKey("foreign")), Is.Not.Null, "the other writer's record survives the merge"); } [Test] @@ -1448,28 +1368,6 @@ namespace GameCult.Caching.Tests } } - [Test] - public void PlainDuplicatesFindTheSmallestKeyLiveAfterAFlushAndAfterAReopen() - { - var path = PathOf("gear.cc"); - using (var cache = Open(path)) - { - cache.Commit(batch => batch.Upsert(typeof(VariantGear), Named("nb", "x"), new CultRecordKey("b"))); - cache.Commit(batch => batch.Upsert(typeof(VariantGear), Named("na", "x"), new CultRecordKey("a"))); - Assert.That(cache.GetByIndex("code", "x")!.Name, Is.EqualTo("na"), "live"); - cache.BackingStores[0].PushAll(); - Assert.That(cache.GetByIndex("code", "x")!.Name, Is.EqualTo("na"), "after a flush"); - } - - using (var reopened = Open(path)) - { - Assert.That(reopened.GetByIndex("code", "x")!.Name, Is.EqualTo("na"), "after a reopen"); - reopened.Remove(new CultRecordKey("a")); - reopened.Commit(batch => batch.Upsert(typeof(VariantGear), Named("na", "x"), new CultRecordKey("a"))); - Assert.That(reopened.GetByIndex("code", "x")!.Name, Is.EqualTo("na"), "rewriting the smaller key does not change the choice"); - } - } - [Test] public void ADroppedSlotIsReportedOncePerRecord() { diff --git a/tests/GameCult.Networking.Tests/CultNetReplicaCommitTests.cs b/tests/GameCult.Networking.Tests/CultNetReplicaCommitTests.cs new file mode 100644 index 00000000..0e2f2031 --- /dev/null +++ b/tests/GameCult.Networking.Tests/CultNetReplicaCommitTests.cs @@ -0,0 +1,621 @@ +#nullable enable +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GameCult.Caching; +using R3; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Networking.Tests +{ + // variants:ruling:replica-commit-grouped-changes. The authoritative cache judges a commit on its result; a replica applying the + // changes one record at a time would judge each intermediate state, where a swap of two [CultIndex] values holds two holders. + // The subscription server tags the changes of one cache commit, the client applies them as one commit, and a replica that + // refuses a commit says so. No transport: the server and client are joined in memory, and every message crosses MessagePack. + public sealed class CultNetReplicaCommitTests + { + [CultDocument("tests.replica_commit_item", "tests.replica_commit_item.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class ReplicaItem + { + [Key(0)] + [CultIndex("sku")] + public string Sku = string.Empty; + + [Key(1)] + public string Note = string.Empty; + } + + // The change message as a peer from before commit identity knew it. + [MessagePackObject] + public sealed class LegacyChange + { + [Key("schemaVersion")] public string SchemaVersion { get; set; } = string.Empty; + [Key("messageId")] public string MessageId { get; set; } = string.Empty; + [Key("subscriptionId")] public string SubscriptionId { get; set; } = string.Empty; + [Key("changeKind")] public string ChangeKind { get; set; } = string.Empty; + [Key("document")] public CultNetRawDocumentRecord? Document { get; set; } + [Key("recordKey")] public string? RecordKey { get; set; } + [Key("schemaId")] public string? SchemaId { get; set; } + } + + private sealed class LoopbackServer : ICultNetSchemaServer + { + private readonly Dictionary _handlers = new(); + public void OnCultNet(Func callback) where T : ICultNetSchemaMessage => _handlers[typeof(T)] = callback; + public void RemoveCultNetMessageListener(Delegate callback) where T : ICultNetSchemaMessage => _handlers.Remove(typeof(T)); + public Task Dispatch(T message, ICultNetSchemaServerPeer peer) where T : ICultNetSchemaMessage => + ((Func)_handlers[typeof(T)])(message, peer); + } + + private sealed class Client : ICultNetSchemaClient + { + private readonly LoopbackServer _server; + private readonly Dictionary> _handlers = new(); + private readonly Peer _peer; + public Client(LoopbackServer server, Peer peer) { _server = server; _peer = peer; peer.Client = this; } + public bool Connected => true; + public void Connect(string host, int port) { } + public void SendCultNet(T message) where T : ICultNetSchemaMessage + { + if (message is CultNetDatabaseSubscribeMessage subscribe) + _server.Dispatch(subscribe, _peer).GetAwaiter().GetResult(); + else if (message is CultNetDatabaseUnsubscribeMessage unsubscribe) + _server.Dispatch(unsubscribe, _peer).GetAwaiter().GetResult(); + } + public void OnCultNet(Action callback) where T : ICultNetSchemaMessage + { + if (!_handlers.TryGetValue(typeof(T), out var list)) _handlers[typeof(T)] = list = new List(); + list.Add(callback); + } + public void Emit(T message) where T : ICultNetSchemaMessage + { + if (_handlers.TryGetValue(typeof(T), out var list)) + foreach (var handler in list.ToArray()) ((Action)handler)(message); + } + public void Dispose() { } + } + + private sealed class Peer : ICultNetSchemaServerPeer + { + public Client? Client; + public ConcurrentQueue Sent { get; } = new(); + public void SendCultNet(T message) where T : ICultNetSchemaMessage + { + // The wire: serialized, then read back as the receiver would. + var bytes = CultNetSchemaMessageSerialization.Serialize(message); + Sent.Enqueue(bytes); + Client!.Emit(CultNetSchemaMessageSerialization.Deserialize(bytes)); + } + public CultNetDatabaseChangeRawMessage[] Changes => Sent + .Select(bytes => CultNetSchemaMessageSerialization.Deserialize(bytes)) + .OfType().ToArray(); + } + + private sealed class Replication : IDisposable + { + public CultCache Source { get; } = new(); + public CultCache Replica { get; } = new(); + public Peer Peer { get; } = new(); + public CultNetDatabaseSubscriptionClient Client { get; } + public List Faults { get; } = new(); + public CultNetDatabase Database { get; } + private readonly CultNetDatabaseSubscriptionServer _server; + + public Replication(Action? seedSource = null, Action? seedReplica = null) + { + seedSource?.Invoke(Source); + seedReplica?.Invoke(Replica); + Database = new CultNetDatabase(Source); + var wire = new LoopbackServer(); + _server = new CultNetDatabaseSubscriptionServer(wire, Database); + var documents = new CultNetDocumentRegistry(Replica.Registry) + .Register(CultNetDocumentBinding.ForDocument(Replica.Registry)); + Client = new CultNetDatabaseSubscriptionClient(new Client(wire, Peer), Replica, documents); + Client.Faulted += Faults.Add; + Client.SubscribeAsync("items", schemaIds: new[] { Source.Registry.GetRequired().SchemaId }) + .GetAwaiter().GetResult(); + } + + public ReplicaItem? ReplicaItem(string key) => Replica.Get(new CultRecordKey(key)); + + public void Dispose() + { + Client.Dispose(); + _server.Dispose(); + Database.Dispose(); + } + } + + private static ReplicaItem Item(string sku) => new() { Sku = sku }; + + private static void Stage(CultCache cache, params (string Key, string Sku)[] items) => + Assert.That(cache.Commit(batch => + { + foreach (var (key, sku) in items) + batch.Upsert(typeof(ReplicaItem), Item(sku), new CultRecordKey(key)); + }), Is.True); + + private static async Task Until(Func condition, int seconds = 5) + { + var deadline = DateTime.UtcNow.AddSeconds(seconds); + while (!condition()) + { + if (DateTime.UtcNow > deadline) Assert.Fail("The replica did not reach the expected state."); + await Task.Delay(5); + } + } + + [Test] + public async Task ASwapOfTwoIndexValuesReachesTheReplicaIntact() + { + using var run = new Replication(source => Stage(source, ("a", "one"), ("b", "two"))); + Assert.That(run.ReplicaItem("a")!.Sku, Is.EqualTo("one")); + + Stage(run.Source, ("a", "two"), ("b", "one")); + await Until(() => run.ReplicaItem("a")!.Sku == "two"); + + Assert.That(run.ReplicaItem("b")!.Sku, Is.EqualTo("one")); + Assert.That(run.Client.Fault, Is.Null); + Assert.That(run.Faults, Is.Empty); + Assert.That(run.Replica.GetByIndex("sku", "two"), Is.SameAs(run.ReplicaItem("a"))); + var swap = run.Peer.Changes; + Assert.That(swap.Select(change => change.CommitId).Distinct().Single(), Is.Not.Null, "the commit is named on the wire"); + Assert.That(swap.Select(change => change.CommitCount), Is.All.EqualTo(2)); + } + + [Test] + public async Task AValueMovedToANewRecordWhileItsOldHolderIsRemovedReachesTheReplica() + { + using var run = new Replication(source => Stage(source, ("a", "one"))); + + Assert.That(run.Source.Commit(batch => + { + batch.Remove(new CultRecordKey("a")); + batch.Upsert(typeof(ReplicaItem), Item("one"), new CultRecordKey("c")); + }), Is.True); + await Until(() => run.ReplicaItem("c") != null); + + Assert.That(run.ReplicaItem("a"), Is.Null); + Assert.That(run.Client.Fault, Is.Null); + Assert.That(run.Replica.GetByIndex("sku", "one"), Is.SameAs(run.ReplicaItem("c"))); + } + + [Test] + public async Task ACommitOfOneChangeCarriesNoCommitFieldsAndIsApplied() + { + using var run = new Replication(); + + Stage(run.Source, ("a", "one")); + await Until(() => run.ReplicaItem("a") != null); + + var change = run.Peer.Changes.Single(); + Assert.That(change.CommitId, Is.Null); + Assert.That(change.CommitCount, Is.Null); + } + + [Test] + public async Task AReplicaThatRefusesACommitFaultsLoudlyAndAppliesNothingMore() + { + // The replica holds a record the source does not, under the value the source now gives to another record: a conflict + // the replica cannot resolve, not an intermediate state. + using var run = new Replication(seedReplica: replica => Stage(replica, ("local", "taken"))); + + Stage(run.Source, ("a", "taken"), ("b", "free")); + await Until(() => run.Client.Fault != null); + + Assert.That(run.Faults, Has.Count.EqualTo(1)); + Assert.That(run.Faults[0], Is.SameAs(run.Client.Fault)); + Assert.That(run.Faults[0].Message, Does.Contain("sku")); + Assert.That(run.ReplicaItem("a"), Is.Null, "the refused commit is not partly applied"); + Assert.That(run.ReplicaItem("b"), Is.Null, "the refused commit is not partly applied"); + + Stage(run.Source, ("c", "later")); + await Task.Delay(100); + Assert.That(run.ReplicaItem("c"), Is.Null, "a replica known to differ takes nothing on top"); + Assert.That(run.Faults, Has.Count.EqualTo(1)); + } + + [Test] + public void AnOlderPeerReadsTheChangeAndIgnoresTheCommitFields() + { + var tagged = new CultNetDatabaseChangeRawMessage + { + MessageId = "m", + SubscriptionId = "items", + ChangeKind = "removed", + RecordKey = "a", + SchemaId = "tests.replica_commit_item.v1", + CommitId = "7", + CommitCount = 2 + }; + + var legacy = MessagePackSerializer.Deserialize( + CultNetSchemaMessageSerialization.Serialize(tagged), CultNetSchemaMessageSerialization.Options); + + Assert.That(legacy.ChangeKind, Is.EqualTo("removed")); + Assert.That(legacy.RecordKey, Is.EqualTo("a")); + Assert.That(legacy.SubscriptionId, Is.EqualTo("items")); + } + + [Test] + public async Task ASwapFromAServerThatDoesNotTagCommitsStillFaultsInsteadOfDiverging() + { + // An older server sends the two halves of a swap untagged: each is judged alone and refused. The refusal is surfaced. + using var run = new Replication(source => Stage(source, ("a", "one"), ("b", "two"))); + var raw = (string key, string sku) => run.Database.Documents.ToRawRecord( + run.Source.Registry.GetRequired(), new CultRecordKey(key), Item(sku), "2026-01-01T00:00:00Z"); + + run.Peer.Client!.Emit(new CultNetDatabaseChangeRawMessage + { + MessageId = "x", SubscriptionId = "items", ChangeKind = "updated", Document = raw("a", "two") + }); + await Until(() => run.Client.Fault != null); + + Assert.That(run.ReplicaItem("a")!.Sku, Is.EqualTo("one")); + } + + [Test] + public async Task ARotationOfThreeValuesReachesTheReplicaIntact() + { + using var run = new Replication(source => Stage(source, ("a", "one"), ("b", "two"), ("c", "three"))); + + Stage(run.Source, ("a", "two"), ("b", "three"), ("c", "one")); + await Until(() => run.ReplicaItem("a")!.Sku == "two"); + + Assert.That(new[] { "a", "b", "c" }.Select(key => run.ReplicaItem(key)!.Sku), Is.EqualTo(new[] { "two", "three", "one" })); + Assert.That(run.Client.Fault, Is.Null); + Assert.That(run.Peer.Changes.Select(change => change.CommitCount), Is.All.EqualTo(3)); + } + + // The database server is the other producer of change messages; it names a commit the same way. + [Test] + public void TheDatabaseServerTagsTheChangesOfOneCommitAndNotALoneChange() + { + var cache = new CultCache(); + Stage(cache, ("a", "one"), ("b", "two")); + using var database = new CultNetDatabase(cache); + using var server = new Server(cache, ServerSecurityOptions.Development()); + using var databaseServer = new CultNetDatabaseServer(server, database); + var commits = new List(); + using var watch = database.WatchCommits().Subscribe(commits.Add); + var selection = new CultNetSelection + { + Schemas = new[] { cache.Registry.GetRequired().SchemaId }, + Projection = CultNetSelectionProjections.Document + }; + IReadOnlyList Deliver(CultNetDatabaseCommit commit) => + (IReadOnlyList)typeof(CultNetDatabaseServer) + .GetMethod("DeliverCommit", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)! + .Invoke(databaseServer, new object[] { commit, "sub", selection, new HashSet(), (Action)(_ => { }) })!; + + Stage(cache, ("a", "two"), ("b", "one")); + Stage(cache, ("c", "three")); + + var swap = Deliver(commits[0]); + Assert.That(swap, Has.Count.EqualTo(2)); + Assert.That(swap.Select(change => change.CommitId).Distinct().Single(), Is.EqualTo(commits[0].Id)); + Assert.That(swap.Select(change => change.CommitCount), Is.All.EqualTo(2)); + var lone = Deliver(commits[1]).Single(); + Assert.That(lone.CommitId, Is.Null); + Assert.That(lone.CommitCount, Is.Null); + Assert.That(commits[0].Id, Is.Not.EqualTo(commits[1].Id)); + } + + [Test] + public void ASnapshotThatSwapsIndexValuesAgainstWhatTheReplicaHoldsIsApplied() + { + using var run = new Replication( + source => Stage(source, ("a", "two"), ("b", "one")), + replica => Stage(replica, ("a", "one"), ("b", "two"))); + + Assert.That(run.ReplicaItem("a")!.Sku, Is.EqualTo("two")); + Assert.That(run.ReplicaItem("b")!.Sku, Is.EqualTo("one")); + } + + // Hand-made wire messages: what a peer sends that the real server would not. + private static CultNetDatabaseChangeRawMessage Put(Replication run, string subscription, string key, string sku, string? commit, int count, string? id = null) => + new() + { + MessageId = id ?? Guid.NewGuid().ToString("N"), + SubscriptionId = subscription, + ChangeKind = "updated", + Document = run.Database.Documents.ToRawRecord( + run.Source.Registry.GetRequired(), new CultRecordKey(key), Item(sku), "2026-01-01T00:00:00Z"), + CommitId = commit, + CommitCount = commit == null ? null : count + }; + + private static CultNetDatabaseChangeRawMessage Removal(string subscription, string key, string commit, int count) => + new() + { + MessageId = Guid.NewGuid().ToString("N"), + SubscriptionId = subscription, + ChangeKind = "removed", + RecordKey = key, + SchemaId = "tests.replica_commit_item.v1", + CommitId = commit, + CommitCount = count + }; + + [Test] + public async Task TwoSubscriptionsMayUseOneCommitIdWithoutJoiningTheirCommits() + { + using var run = new Replication(source => Stage(source, ("a", "one"), ("b", "two"))); + run.Client.SubscribeAsync("other", recordKeys: new[] { "nothing-matches" }).GetAwaiter().GetResult(); + var emit = run.Peer.Client!; + + emit.Emit(Put(run, "items", "a", "two", "9", 2)); + emit.Emit(Put(run, "other", "x", "ex", "9", 2)); + await Task.Delay(100); + Assert.That(run.ReplicaItem("a")!.Sku, Is.EqualTo("one"), "one change of each of two commits is not a commit"); + Assert.That(run.ReplicaItem("x"), Is.Null); + + emit.Emit(Put(run, "items", "b", "one", "9", 2)); + await Until(() => run.ReplicaItem("a")!.Sku == "two"); + Assert.That(run.ReplicaItem("b")!.Sku, Is.EqualTo("one")); + Assert.That(run.ReplicaItem("x"), Is.Null); + Assert.That(run.Client.Fault, Is.Null); + } + + [Test] + public async Task ARemovalAfterTheFirstChangeOfACommitIsApplied() + { + using var run = new Replication(source => Stage(source, ("a", "one"))); + var emit = run.Peer.Client!; + + emit.Emit(Put(run, "items", "c", "one", "5", 2)); + emit.Emit(Removal("items", "a", "5", 2)); + await Until(() => run.ReplicaItem("c") != null); + + Assert.That(run.ReplicaItem("a"), Is.Null); + Assert.That(run.Client.Fault, Is.Null); + } + + [Test] + public void ASnapshotOfManyDocumentsAppliesEveryOne() + { + using var run = new Replication(source => Stage(source, ("a", "one"), ("b", "two"), ("c", "three"), ("d", "four"))); + + Assert.That(new[] { "a", "b", "c", "d" }.Select(key => run.ReplicaItem(key)?.Sku), Is.EqualTo(new[] { "one", "two", "three", "four" })); + } + + [Test] + public async Task ManyCommitsInARowAreNotMistakenForAnUnboundedBuffer() + { + using var run = new Replication(source => Stage(source, ("a", "s0"), ("b", "s1"))); + + for (var round = 1; round <= 80; round++) + { + var a = "s" + (round % 2); + var b = "s" + ((round + 1) % 2); + Stage(run.Source, ("a", a), ("b", b)); + } + + Stage(run.Source, ("a", "last-a"), ("b", "last-b")); + await Until(() => run.Client.Fault != null || run.ReplicaItem("a")!.Sku == "last-a"); + Assert.That(run.Client.Fault, Is.Null, "completed commits leave the buffer"); + Assert.That(run.ReplicaItem("b")!.Sku, Is.EqualTo("last-b")); + } + + [Test] + public async Task AMessageDeliveredTwiceInOneCommitFaults() + { + using var run = new Replication(); + + run.Peer.Client!.Emit(Put(run, "items", "a", "one", "3", 3, id: "same")); + run.Peer.Client!.Emit(Put(run, "items", "b", "two", "3", 3, id: "same")); + await Until(() => run.Client.Fault != null); + + Assert.That(run.Faults, Has.Count.EqualTo(1)); + Assert.That(run.ReplicaItem("a"), Is.Null); + } + + [Test] + public async Task ACommitThatChangesItsAnnouncedSizeFaults() + { + using var run = new Replication(); + + run.Peer.Client!.Emit(Put(run, "items", "a", "one", "3", 2)); + run.Peer.Client!.Emit(Put(run, "items", "b", "two", "3", 3)); + await Until(() => run.Client.Fault != null); + + Assert.That(run.ReplicaItem("a"), Is.Null); + } + + [Test] + public async Task ACommitAnnouncingMoreChangesThanAReplicaHoldsFaults() + { + using var run = new Replication(); + + run.Peer.Client!.Emit(Put(run, "items", "a", "one", "3", 1_000_001)); + await Until(() => run.Client.Fault != null); + } + + [Test] + public async Task MoreIncompleteCommitsThanTheBoundFault() + { + using var run = new Replication(); + + for (var commit = 0; commit < 64; commit++) + run.Peer.Client!.Emit(Put(run, "items", "k" + commit, "v" + commit, "c" + commit, 2)); + await Task.Delay(200); + Assert.That(run.Client.Fault, Is.Null, "64 incomplete commits are within the bound"); + Assert.That(Pending(run.Client), Is.EqualTo(64)); + + run.Peer.Client!.Emit(Put(run, "items", "k64", "v64", "c64", 2)); + await Until(() => run.Client.Fault != null); + Assert.That(Pending(run.Client), Is.Zero, "a fault drops what was waiting"); + } + + [Test] + public async Task UnsubscribingDropsTheCommitsStillWaiting() + { + using var run = new Replication(); + var emit = run.Peer.Client!; + + emit.Emit(Put(run, "items", "a", "one", "4", 2)); + await Task.Delay(50); + run.Client.Unsubscribe("items"); + emit.Emit(Put(run, "items", "b", "two", "4", 2)); + await Task.Delay(100); + + Assert.That(run.ReplicaItem("a"), Is.Null); + Assert.That(run.ReplicaItem("b"), Is.Null, "the second half of a dropped commit is not a commit"); + } + + [Test] + public async Task AFaultedClientRefusesANewSubscription() + { + using var run = new Replication(seedReplica: replica => Stage(replica, ("local", "taken"))); + Stage(run.Source, ("a", "taken")); + await Until(() => run.Client.Fault != null); + + var refusal = Assert.Throws(() => run.Client.SubscribeAsync("again")); + Assert.That(refusal!.InnerException, Is.SameAs(run.Client.Fault)); + } + + private static int Pending(CultNetDatabaseSubscriptionClient client) + { + var commits = (System.Collections.IDictionary)typeof(CultNetDatabaseSubscriptionClient) + .GetField("_commits", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!.GetValue(client)!; + lock (commits) + return commits.Count; + } + + [Test] + public async Task TheIncompleteCommitsMayAnnounceOneMillionChangesBetweenThem() + { + using var run = new Replication(); + + run.Peer.Client!.Emit(Put(run, "items", "a", "one", "1", 500_000)); + run.Peer.Client!.Emit(Put(run, "items", "b", "two", "2", 500_000)); + await Task.Delay(200); + Assert.That(run.Client.Fault, Is.Null, "500k and 500k fit"); + + run.Peer.Client!.Emit(Put(run, "items", "c", "three", "3", 2)); + await Until(() => run.Client.Fault != null); + } + + [Test] + public async Task ACommitOfOneMillionChangesIsHeldAndOneMillionAndOneIsNot() + { + using var held = new Replication(); + held.Peer.Client!.Emit(Put(held, "items", "a", "one", "1", 1_000_000)); + await Task.Delay(200); + Assert.That(held.Client.Fault, Is.Null); + Assert.That(Pending(held.Client), Is.EqualTo(1)); + + using var over = new Replication(); + over.Peer.Client!.Emit(Put(over, "items", "a", "one", "1", 1_000_001)); + await Until(() => over.Client.Fault != null); + } + + [Test] + public async Task ACommitThatShrinksItsAnnouncedSizeFaults() + { + using var run = new Replication(); + + run.Peer.Client!.Emit(Put(run, "items", "a", "one", "3", 3)); + run.Peer.Client!.Emit(Put(run, "items", "b", "two", "3", 2)); + await Until(() => run.Client.Fault != null); + } + + [Test] + public async Task UnsubscribingOneSubscriptionKeepsTheCommitsOfAnother() + { + using var run = new Replication(); + run.Client.SubscribeAsync("items2", recordKeys: new[] { "nothing-matches" }).GetAwaiter().GetResult(); + var emit = run.Peer.Client!; + + emit.Emit(Put(run, "items", "a", "one", "4", 2)); + emit.Emit(Put(run, "items2", "b", "two", "4", 2)); + await Task.Delay(100); + Assert.That(Pending(run.Client), Is.EqualTo(2)); + + run.Client.Unsubscribe("items"); + Assert.That(Pending(run.Client), Is.EqualTo(1), "only the unsubscribed subscription's commits leave"); + emit.Emit(Put(run, "items2", "c", "three", "4", 2)); + await Until(() => run.ReplicaItem("b") != null); + Assert.That(run.ReplicaItem("c"), Is.Not.Null); + Assert.That(run.ReplicaItem("a"), Is.Null); + } + + [Test] + public async Task AStragglerAfterUnsubscribeOrFromAnUnknownSubscriptionIsNotBuffered() + { + using var run = new Replication(); + run.Client.Unsubscribe("items"); + + run.Peer.Client!.Emit(Put(run, "items", "a", "one", "4", 2)); + run.Peer.Client!.Emit(Put(run, "never-subscribed", "b", "two", "4", 2)); + await Task.Delay(150); + + Assert.That(Pending(run.Client), Is.Zero); + Assert.That(run.Client.Fault, Is.Null); + } + + [Test] + public void DisposingTheClientDropsTheCommitsStillWaiting() + { + var run = new Replication(); + run.Peer.Client!.Emit(Put(run, "items", "a", "one", "4", 2)); + SpinWait.SpinUntil(() => Pending(run.Client) == 1, 2000); + Assert.That(Pending(run.Client), Is.EqualTo(1)); + + run.Dispose(); + + Assert.That(Pending(run.Client), Is.Zero); + } + + [Test] + public async Task AFaultedClientBuffersNothingMore() + { + using var run = new Replication(); + run.Peer.Client!.Emit(Put(run, "items", "a", "one", "3", 3, id: "same")); + run.Peer.Client!.Emit(Put(run, "items", "b", "two", "3", 3, id: "same")); + await Until(() => run.Client.Fault != null); + + run.Peer.Client!.Emit(Put(run, "items", "c", "three", "8", 2)); + run.Peer.Client!.Emit(Put(run, "items", "d", "four", "9", 2)); + await Task.Delay(150); + + Assert.That(Pending(run.Client), Is.Zero); + Assert.That(run.Faults, Has.Count.EqualTo(1), "a fault is reported once"); + } + + [Test] + public void AFaultIsReportedOnceHoweverOftenItIsRaised() + { + using var run = new Replication(); + var raise = typeof(CultNetDatabaseSubscriptionClient) + .GetMethod("Break", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!; + + raise.Invoke(run.Client, new object[] { new InvalidOperationException("first") }); + raise.Invoke(run.Client, new object[] { new InvalidOperationException("second") }); + + Assert.That(run.Faults, Has.Count.EqualTo(1)); + Assert.That(run.Client.Fault!.Message, Is.EqualTo("first")); + } + + [TestCase(5000)] + [TestCase(100_000)] + public async Task ACommitOfManyChangesThroughTheRealSubscriptionServerAppliesIntact(int size) + { + using var run = new Replication(); + var keys = Enumerable.Range(0, size).Select(i => ("k" + i, "s" + i)).ToArray(); + + Stage(run.Source, keys); + await Until(() => run.ReplicaItem("k" + (size - 1)) != null || run.Client.Fault != null, seconds: 180); + + Assert.That(run.Client.Fault, Is.Null); + Assert.That(run.Replica.GetAll().Count(), Is.EqualTo(size)); + Assert.That(run.Peer.Changes.Select(change => change.CommitCount).Distinct().Single(), Is.EqualTo(size)); + Assert.That(run.ReplicaItem("k" + (size / 2))!.Sku, Is.EqualTo("s" + (size / 2))); + } + } +} diff --git a/tests/GameCult.Networking.Tests/CultNetSelectionEvaluatorTests.cs b/tests/GameCult.Networking.Tests/CultNetSelectionEvaluatorTests.cs index b2dfcf3c..b700ec19 100644 --- a/tests/GameCult.Networking.Tests/CultNetSelectionEvaluatorTests.cs +++ b/tests/GameCult.Networking.Tests/CultNetSelectionEvaluatorTests.cs @@ -486,6 +486,39 @@ namespace GameCult.Networking.Tests Assert.That(evaluation.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "a" })); } + // [CultIndex] means unique and is also a selection address: a unique member filters by equality and by number like a role. + [Test] + public void AUniqueIndexMemberIsAlsoSelectable() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(SelUnique) }); + var rows = new[] + { + Row(registry, new SelUnique { Sku = "s1", Rank = 1 }, "a", 1), + Row(registry, new SelUnique { Sku = "s2", Rank = 7 }, "b", 2) + }; + + var byValue = CultNetSelectionEvaluator.Select(registry, rows, + new CultNetSelection { Fields = new[] { new CultNetFieldPredicate { Index = "sku", Op = "any_of", Values = new[] { "s2" } } } }, asOf: 2); + var byNumber = CultNetSelectionEvaluator.Select(registry, rows, + new CultNetSelection { Fields = new[] { new CultNetFieldPredicate { Index = "rank", Op = "gt", Number = "3" } } }, asOf: 2); + + Assert.That(byValue.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "b" })); + Assert.That(byNumber.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "b" })); + } + + [CultDocument("cultnet.selection-tests.unique", "cultnet.selection-tests.unique.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class SelUnique + { + [Key(0)] + [CultIndex("sku")] + public string Sku = string.Empty; + + [Key(1)] + [CultIndex("rank")] + public int Rank; + } + public abstract class SelFixtureMiddle { [Key(0)] @@ -493,11 +526,11 @@ namespace GameCult.Networking.Tests public string Name = string.Empty; [Key(1)] - [CultIndex("mass")] + [CultRole("mass")] public float Mass; [Key(2)] - [CultIndex("kind")] + [CultRole("kind")] public string Kind = string.Empty; } diff --git a/tests/GameCult.Networking.Tests/CultNetSelectionFixBatchTests.cs b/tests/GameCult.Networking.Tests/CultNetSelectionFixBatchTests.cs index 9ecc4556..030ae100 100644 --- a/tests/GameCult.Networking.Tests/CultNetSelectionFixBatchTests.cs +++ b/tests/GameCult.Networking.Tests/CultNetSelectionFixBatchTests.cs @@ -581,7 +581,7 @@ namespace GameCult.Networking.Tests return instance; } - // A plain data member "Owner" with no [CultIndex] (its role falls back to its bare name) + // A plain data member "Owner" with no [CultRole] (its role falls back to its bare name) // and a reference member whose alias is explicitly "Owner" - the exact shape Soul probed. var ownerMember = MakeMember(typeof(GuardProbeShape).GetField(nameof(GuardProbeShape.Owner))!, typeof(string), 0, indexAlias: null, isReference: false, targetType: null); diff --git a/tests/GameCult.Networking.Tests/CultNetSelectionPageTests.cs b/tests/GameCult.Networking.Tests/CultNetSelectionPageTests.cs index 0f1f20a9..845f9468 100644 --- a/tests/GameCult.Networking.Tests/CultNetSelectionPageTests.cs +++ b/tests/GameCult.Networking.Tests/CultNetSelectionPageTests.cs @@ -130,7 +130,7 @@ namespace GameCult.Networking.Tests public string Name = string.Empty; [Key(1)] - [CultIndex("kind")] + [CultRole("kind")] public string Kind = string.Empty; } diff --git a/tests/GameCult.Networking.Tests/CultNetSelectionSurvivorTests.cs b/tests/GameCult.Networking.Tests/CultNetSelectionSurvivorTests.cs index a03b62d2..e625c134 100644 --- a/tests/GameCult.Networking.Tests/CultNetSelectionSurvivorTests.cs +++ b/tests/GameCult.Networking.Tests/CultNetSelectionSurvivorTests.cs @@ -62,7 +62,7 @@ namespace GameCult.Networking.Tests public sealed class SurvivorNumericLeaf { [Key(0)] [CultName] public string Name = string.Empty; - [Key(1)] [CultIndex("level")] public int Level; + [Key(1)] [CultRole("level")] public int Level; } [CultDocument("cultnet.selection-tests.survivor_string_leaf", "cultnet.selection-tests.survivor_string_leaf.v1")] @@ -70,7 +70,7 @@ namespace GameCult.Networking.Tests public sealed class SurvivorStringLeaf { [Key(0)] [CultName] public string Name = string.Empty; - [Key(1)] [CultIndex("level")] public string Level = string.Empty; + [Key(1)] [CultRole("level")] public string Level = string.Empty; } // --------------------------------------------------------------------------------------------- @@ -173,7 +173,7 @@ namespace GameCult.Networking.Tests // The declared index alias ("linkRole") differs from the member name ("Link") - the exact // shape section 2's role-naming rule exists for, and no prior fixture had one. [Key(1)] - [CultIndex("linkRole")] + [CultRole("linkRole")] [CultReference(typeof(SurvivorAliasTarget))] public CultRecordRef Link; } @@ -408,7 +408,7 @@ namespace GameCult.Networking.Tests [Key(0)] [CultName] public string Name = string.Empty; [Key(1)] - [CultIndex("hopRole")] + [CultRole("hopRole")] [CultReference(typeof(SurvivorAliasTarget))] public CultRecordRef Link; } @@ -431,12 +431,12 @@ namespace GameCult.Networking.Tests [Key(0)] [CultName] public string Name = string.Empty; [Key(1)] - [CultIndex("zLastRole")] + [CultRole("zLastRole")] [CultReference(typeof(SurvivorAliasTarget))] public CultRecordRef RefZ; [Key(2)] - [CultIndex("aFirstRole")] + [CultRole("aFirstRole")] [CultReference(typeof(SurvivorAliasTarget))] public CultRecordRef RefA; } diff --git a/tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs b/tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs index e9a070fc..3a076d16 100644 --- a/tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs +++ b/tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs @@ -586,11 +586,11 @@ namespace GameCult.Networking.Tests public string Name = string.Empty; [Key(1)] - [CultIndex("kind")] + [CultRole("kind")] public string Kind = string.Empty; [Key(2)] - [CultIndex("mass")] + [CultRole("mass")] public float Mass; } @@ -631,7 +631,7 @@ namespace GameCult.Networking.Tests public string Name = string.Empty; [Key(1)] - [CultIndex("narrow_ref")] + [CultRole("narrow_ref")] [CultReference(typeof(ParityLeafA))] public CultRecordRef NarrowRef; } From abd1a92eb24e1af3fc7cdd1bf98a88dbdebc2938 Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 04:57:59 +0000 Subject: [PATCH 08/93] Replica commit hardening: Reconcile sends its diff as one tagged commit; straggler guard inside the lock; real-server and budget pins (#91) --- .../CultNetDatabaseSubscriptionClient.cs | 7 +- .../CultNetDatabaseSubscriptionServer.cs | 15 +- .../CultNetReplicaCommitTests.cs | 199 +++++++++++++++++- 3 files changed, 214 insertions(+), 7 deletions(-) diff --git a/src/GameCult.Networking/CultNetDatabaseSubscriptionClient.cs b/src/GameCult.Networking/CultNetDatabaseSubscriptionClient.cs index 971a53c2..bf3772e6 100644 --- a/src/GameCult.Networking/CultNetDatabaseSubscriptionClient.cs +++ b/src/GameCult.Networking/CultNetDatabaseSubscriptionClient.cs @@ -245,14 +245,13 @@ namespace GameCult.Networking return Apply(deliveryMode, new[] { message }); // The commit's other changes follow on this connection; the replica applies them as one commit. - // A straggler of a subscription that was ended, or never made, is not buffered. - if (!_deliveryModes.ContainsKey(message.SubscriptionId)) - return Task.CompletedTask; var held = (message.SubscriptionId, message.CommitId); PendingCommit? pending; lock (_commits) { - if (Fault != null) + // A straggler of a subscription that was ended, or never made, is not buffered. Judged under the lock that + // Unsubscribe evicts under: a check before it could pass, lose the race to the eviction, and then buffer. + if (Fault != null || !_deliveryModes.ContainsKey(message.SubscriptionId)) return Task.CompletedTask; var count = message.CommitCount!.Value; var refusal = !_commits.TryGetValue(held, out pending) diff --git a/src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs b/src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs index 1325f85e..3e23cf10 100644 --- a/src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs +++ b/src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs @@ -77,7 +77,20 @@ namespace GameCult.Networking { if (_disposed) return; foreach (var entry in _requests.ToArray()) - Reconcile(entry.Key, entry.Value); + { + // Reconcile can run after the cache has admitted a commit and before Watch has seen it, so its diff can carry + // changed values; it is held and sent as one commit of its own, as Watch's is, for the replica to judge whole. + var outbox = _outbox = new CommitOutbox(Guid.NewGuid().ToString("N")); + try + { + Reconcile(entry.Key, entry.Value); + } + finally + { + _outbox = null; + outbox.Send(entry.Key.Peer); + } + } } } diff --git a/tests/GameCult.Networking.Tests/CultNetReplicaCommitTests.cs b/tests/GameCult.Networking.Tests/CultNetReplicaCommitTests.cs index 0e2f2031..6059d7ba 100644 --- a/tests/GameCult.Networking.Tests/CultNetReplicaCommitTests.cs +++ b/tests/GameCult.Networking.Tests/CultNetReplicaCommitTests.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using GameCult.Caching; +using LiteNetLib; using R3; using MessagePack; using NUnit.Framework; @@ -105,14 +106,18 @@ namespace GameCult.Networking.Tests public List Faults { get; } = new(); public CultNetDatabase Database { get; } private readonly CultNetDatabaseSubscriptionServer _server; + public void Reconcile() => _server.Reconcile(); + public object Gate => typeof(CultNetDatabaseSubscriptionServer) + .GetField("_lifecycleGate", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!.GetValue(_server)!; - public Replication(Action? seedSource = null, Action? seedReplica = null) + public Replication(Action? seedSource = null, Action? seedReplica = null, + Func? authorizeRecord = null) { seedSource?.Invoke(Source); seedReplica?.Invoke(Replica); Database = new CultNetDatabase(Source); var wire = new LoopbackServer(); - _server = new CultNetDatabaseSubscriptionServer(wire, Database); + _server = new CultNetDatabaseSubscriptionServer(wire, Database, authorizeRecord: authorizeRecord); var documents = new CultNetDocumentRegistry(Replica.Registry) .Register(CultNetDocumentBinding.ForDocument(Replica.Registry)); Client = new CultNetDatabaseSubscriptionClient(new Client(wire, Peer), Replica, documents); @@ -602,6 +607,196 @@ namespace GameCult.Networking.Tests Assert.That(run.Client.Fault!.Message, Is.EqualTo("first")); } + private static object CommitsOf(CultNetDatabaseSubscriptionClient client) => + typeof(CultNetDatabaseSubscriptionClient) + .GetField("_commits", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!.GetValue(client)!; + + // The check that a subscription is live belongs to the same lock Unsubscribe evicts under. The test holds that lock (it is + // re-entrant), waits until the straggler's handler is contending for it, ends the subscription, and releases: a handler + // that had already passed its check by then would buffer a commit nobody will ever finish. + [Test] + public void AStragglerThatArrivedBeforeUnsubscribeBuffersNothingAfterIt() + { + using var run = new Replication(); + Task tail; + + lock (CommitsOf(run.Client)) + { + var contended = Monitor.LockContentionCount; + run.Peer.Client!.Emit(Put(run, "items", "a", "one", "4", 2)); + Assert.That(SpinWait.SpinUntil(() => Monitor.LockContentionCount > contended, 10_000), "the handler never reached the lock"); + run.Client.Unsubscribe("items"); + tail = (Task)typeof(CultNetDatabaseSubscriptionClient) + .GetField("_queue", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!.GetValue(run.Client)!; + } + tail.GetAwaiter().GetResult(); + + Assert.That(Pending(run.Client), Is.Zero); + Assert.That(run.Client.Fault, Is.Null); + } + + // The 1,000,000 budget is the client's, over every subscription's incomplete commits, not each subscription's own. + [Test] + public async Task TheBudgetIsSharedByAllSubscriptions() + { + using var run = new Replication(); + run.Client.SubscribeAsync("items2", recordKeys: new[] { "nothing-matches" }).GetAwaiter().GetResult(); + + run.Peer.Client!.Emit(Put(run, "items", "a", "one", "1", 600_000)); + run.Peer.Client!.Emit(Put(run, "items2", "b", "two", "2", 400_000)); + await Task.Delay(200); + Assert.That(run.Client.Fault, Is.Null, "600k and 400k fit in one million"); + Assert.That(Pending(run.Client), Is.EqualTo(2)); + + run.Peer.Client!.Emit(Put(run, "items2", "c", "three", "3", 2)); + await Until(() => run.Client.Fault != null); + } + + [Test] + public async Task TwoSubscriptionsAnnouncingMoreThanTheBudgetBetweenThemFault() + { + using var run = new Replication(); + run.Client.SubscribeAsync("items2", recordKeys: new[] { "nothing-matches" }).GetAwaiter().GetResult(); + + run.Peer.Client!.Emit(Put(run, "items", "a", "one", "1", 600_000)); + run.Peer.Client!.Emit(Put(run, "items2", "b", "two", "2", 600_000)); + await Until(() => run.Client.Fault != null); + Assert.That(Pending(run.Client), Is.Zero); + } + + [Test] + public async Task AStragglerFromAnUnknownIdIsDroppedWhileAnotherSubscriptionIsLive() + { + using var run = new Replication(); + run.Client.SubscribeAsync("items2", recordKeys: new[] { "nothing-matches" }).GetAwaiter().GetResult(); + run.Peer.Client!.Emit(Put(run, "items2", "b", "two", "4", 2)); + await Until(() => Pending(run.Client) == 1); + + run.Peer.Client!.Emit(Put(run, "items-ghost", "a", "one", "4", 2)); + run.Peer.Client!.Emit(Put(run, "items-ghost", "c", "three", "5", 3)); + run.Peer.Client!.Emit(Put(run, "item", "e", "five", "6", 2)); + await Task.Delay(200); + + Assert.That(Pending(run.Client), Is.EqualTo(1), "only the live subscription's commit is held"); + Assert.That(run.Client.Fault, Is.Null); + run.Peer.Client!.Emit(Put(run, "items2", "d", "four", "4", 2)); + await Until(() => run.ReplicaItem("b") != null); + Assert.That(run.ReplicaItem("a"), Is.Null); + } + + // Reconcile() can run after the cache admitted a commit and before the subscription's Watch has seen it (the observer + // runs on the writer's thread after the cache gate opens). Its diff then carries changed values, and a swap sent record by + // record faults the replica. The test holds the subscription server's gate to stand in that window. + [Test] + public async Task AReconcileThatRacesACommitSendsItsSwapAsOneCommit() + { + using var run = new Replication(source => Stage(source, ("a", "one"), ("b", "two"))); + Task write; + + lock (run.Gate) + { + write = Task.Run(() => Stage(run.Source, ("a", "two"), ("b", "one"))); + Assert.That(SpinWait.SpinUntil(() => run.Source.Get(new CultRecordKey("a"))!.Sku == "two", 10_000), + "the source did not take the swap"); + run.Reconcile(); + } + await write; + await Until(() => run.ReplicaItem("a")!.Sku == "two"); + + Assert.That(run.ReplicaItem("b")!.Sku, Is.EqualTo("one")); + Assert.That(run.Client.Fault, Is.Null); + Assert.That(run.Peer.Changes.Select(change => change.CommitCount), Is.All.EqualTo(2)); + } + + [Test] + public async Task ReconcilingAfterAPolicyChangeThatFollowsASwapLeavesTheReplicaIntact() + { + var hidden = new HashSet(); + using var run = new Replication(source => Stage(source, ("a", "one"), ("b", "two")), + authorizeRecord: (_, _, key, _) => !hidden.Contains(key)); + hidden.Add("b"); + + Stage(run.Source, ("a", "two"), ("b", "one")); + await Until(() => run.ReplicaItem("b") == null && run.ReplicaItem("a")!.Sku == "two"); + + hidden.Remove("b"); + hidden.Add("a"); + run.Reconcile(); + await Until(() => run.ReplicaItem("b") != null && run.ReplicaItem("a") == null); + + Assert.That(run.ReplicaItem("b")!.Sku, Is.EqualTo("one")); + Assert.That(run.Client.Fault, Is.Null); + } + + // Source, wire and client all real: a CultNetDatabaseServer behind a Server on a loopback socket, a LiteNetLib client + // standing in for the transport of a CultNetDatabaseSubscriptionClient. No handler is called directly. + private sealed class SocketClient : ICultNetSchemaClient + { + private readonly NetManager _manager; + private readonly NetPeer _peer; + private readonly List> _snapshots = new(); + private readonly List> _changes = new(); + + public SocketClient(int port, string key) + { + var listener = new EventBasedNetListener(); + var connected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var decoder = new LiteNetLibTransportConnection(); + listener.PeerConnectedEvent += _ => connected.TrySetResult(true); + listener.NetworkReceiveEvent += (_, reader, _, _) => + { + switch (LiteNetLibTransportConnection.DecodeSchema(decoder.Receive(reader.GetRemainingBytes()))) + { + case CultNetSnapshotResponseRawMessage snapshot: foreach (var handler in _snapshots.ToArray()) handler(snapshot); break; + case CultNetDatabaseChangeRawMessage change: foreach (var handler in _changes.ToArray()) handler(change); break; + } + }; + _manager = new NetManager(listener) { UnsyncedEvents = true }; + _manager.Start(0); + _peer = _manager.Connect("127.0.0.1", port, key); + if (!connected.Task.Wait(TimeSpan.FromSeconds(10))) Assert.Fail("the client did not connect"); + } + + public bool Connected => _peer.ConnectionState == ConnectionState.Connected; + public void Connect(string host, int port) { } + public void SendCultNet(T message) where T : ICultNetSchemaMessage => + _peer.Send(CultNetSchemaMessageSerialization.Serialize(message), DeliveryMethod.ReliableOrdered); + public void OnCultNet(Action callback) where T : ICultNetSchemaMessage + { + if (callback is Action snapshot) _snapshots.Add(snapshot); + else if (callback is Action change) _changes.Add(change); + } + public void Dispose() => _manager.Stop(); + } + + [Test] + public async Task ASwapThroughTheRealDatabaseServerReachesTheClientIntact() + { + var source = new CultCache(); + Stage(source, ("a", "one"), ("b", "two")); + using var database = new CultNetDatabase(source); + using var server = new Server(source, ServerSecurityOptions.Development()) { Port = 0 }; + using var databaseServer = new CultNetDatabaseServer(server, database); + server.Start(); + var replica = new CultCache(); + var documents = new CultNetDocumentRegistry(replica.Registry) + .Register(CultNetDocumentBinding.ForDocument(replica.Registry)); + using var client = new CultNetDatabaseSubscriptionClient( + new SocketClient(server.Port, ServerSecurityOptions.Development().ConnectionKey), replica, documents); + var faults = new List(); + client.Faulted += faults.Add; + await client.SubscribeAsync("items", schemaIds: new[] { source.Registry.GetRequired().SchemaId }); + Assert.That(replica.Get(new CultRecordKey("a"))!.Sku, Is.EqualTo("one")); + + Stage(source, ("a", "two"), ("b", "one")); + await Until(() => replica.Get(new CultRecordKey("a"))!.Sku == "two"); + + Assert.That(replica.Get(new CultRecordKey("b"))!.Sku, Is.EqualTo("one")); + Assert.That(replica.GetByIndex("sku", "one"), Is.SameAs(replica.Get(new CultRecordKey("b")))); + Assert.That(client.Fault, Is.Null); + Assert.That(faults, Is.Empty); + } + [TestCase(5000)] [TestCase(100_000)] public async Task ACommitOfManyChangesThroughTheRealSubscriptionServerAppliesIntact(int size) From 31d86e2a50f49e15a275a694efafdd19bf9e17c4 Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 05:13:08 +0000 Subject: [PATCH 09/93] cultcache-ts and cultnet-ts: indexes means unique, roles names selection aliases; commitId/commitCount on change messages (#92) --- .../cultnet.database-change-raw.schema.json | 4 +- docs/start/README.md | 1 - packages/cultcache-ts/CHANGELOG.md | 9 + packages/cultcache-ts/README.md | 70 ++- packages/cultcache-ts/src/cult-cache.ts | 87 ++- packages/cultcache-ts/src/document.ts | 38 +- packages/cultcache-ts/src/types.ts | 12 + packages/cultcache-ts/test/first-call.test.ts | 1 - .../cultcache-ts/test/unique-index.test.ts | 504 ++++++++++++++++++ packages/cultnet-ts/README.md | 1 + packages/cultnet-ts/src/contracts.ts | 13 + packages/cultnet-ts/test/cultnet.test.ts | 50 ++ .../UniqueIndexVectorTests.cs | 294 ++++++++++ tests/vectors/unique-index/README.md | 24 + tests/vectors/unique-index/blank-store.cc | Bin 0 -> 964 bytes tests/vectors/unique-index/duplicate-store.cc | Bin 0 -> 1222 bytes tests/vectors/unique-index/outcomes.msgpack | 1 + tests/vectors/unique-index/values.msgpack | Bin 0 -> 788 bytes 18 files changed, 1073 insertions(+), 36 deletions(-) create mode 100644 packages/cultcache-ts/test/unique-index.test.ts create mode 100644 tests/GameCult.Caching.Tests/UniqueIndexVectorTests.cs create mode 100644 tests/vectors/unique-index/README.md create mode 100644 tests/vectors/unique-index/blank-store.cc create mode 100644 tests/vectors/unique-index/duplicate-store.cc create mode 100644 tests/vectors/unique-index/outcomes.msgpack create mode 100644 tests/vectors/unique-index/values.msgpack diff --git a/contracts/cultnet/cultnet.database-change-raw.schema.json b/contracts/cultnet/cultnet.database-change-raw.schema.json index d214ed6b..f1cd97eb 100644 --- a/contracts/cultnet/cultnet.database-change-raw.schema.json +++ b/contracts/cultnet/cultnet.database-change-raw.schema.json @@ -13,8 +13,8 @@ "document": { "$ref": "./cultnet.raw-document-record.schema.json" }, "recordKey": { "type": "string", "minLength": 1 }, "schemaId": { "type": "string", "minLength": 1 }, - "commitId": { "type": "string", "minLength": 1 }, - "commitCount": { "type": "integer", "minimum": 2 } + "commitId": { "type": ["string", "null"], "minLength": 1 }, + "commitCount": { "type": ["integer", "null"], "minimum": 2 } }, "allOf": [ { diff --git a/docs/start/README.md b/docs/start/README.md index c388f8b6..e52f7d73 100644 --- a/docs/start/README.md +++ b/docs/start/README.md @@ -94,7 +94,6 @@ const itemDocument = defineDocumentType({ schemaVersion: "gamecult.first_call_item.v1", schema: z.object({ name: z.string(), category: z.string(), value: z.number().int() }), name: "name", - indexes: { category: "category" }, }); const registry = defineDocumentRegistry(itemDocument); diff --git a/packages/cultcache-ts/CHANGELOG.md b/packages/cultcache-ts/CHANGELOG.md index eefea3b5..9850dfe4 100644 --- a/packages/cultcache-ts/CHANGELOG.md +++ b/packages/cultcache-ts/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following ## [Unreleased] +### Breaking + +- `indexes` are unique, as C# `[CultIndex]`: a write or a load that leaves two records of a type holding one non-blank + value under one alias is refused naming both keys and the alias, and writes nothing. Index text is the C# canonical + text (booleans `True`/`False`, integers as digits, doubles as the exact expansion, blank values never indexed), so + an empty string is no longer a value. `registerIndex` over held duplicates installs nothing. A new `roles` map names + the many-row selection aliases (never indexed for lookup, same catalog `indexAlias`). The per-key migration rule is + in the README; classify every key before the CultLib your tools load is updated. The refusal verdict is the contract, its text is informational. + ### Changed - Opening a legacy (pre-v1) store is a pure read: payloads that were not bytes are normalized in memory and the diff --git a/packages/cultcache-ts/README.md b/packages/cultcache-ts/README.md index dc53c42d..97fb3cdd 100644 --- a/packages/cultcache-ts/README.md +++ b/packages/cultcache-ts/README.md @@ -93,12 +93,16 @@ const itemDocument = defineDocumentType({ schemaVersion: "item.v1", schema: z.object({ name: z.string(), + sku: z.string(), category: z.string(), value: z.number().int(), }), name: "name", indexes: { - category: "category", + sku: "sku", // unique: a sku names one item + }, + roles: { + category: "category", // a selection alias: many items share a category }, }); @@ -122,6 +126,7 @@ await cache.pullAllBackingStores(); await cache.put(itemDocument, "item:potion", { name: "Potion", + sku: "POT-001", category: "Consumable", value: 50, }); @@ -132,7 +137,7 @@ await cache.putGlobal(settingsDocument, { }); const potion = cache.getByName(itemDocument, "Potion"); -const consumable = cache.getByIndex(itemDocument, "category", "Consumable"); +const bySku = cache.getByIndex(itemDocument, "sku", "POT-001"); const settings = cache.getRequiredGlobal(settingsDocument); ``` @@ -194,7 +199,7 @@ A document type has exactly one home store ## Name, Index, and Global Semantics -`name` and `indexes` can be declared on the document definition: +`name`, `indexes` and `roles` can be declared on the document definition: ```ts const playerDocument = defineDocumentType({ @@ -202,17 +207,70 @@ const playerDocument = defineDocumentType({ schema: playerSchema, name: "displayName", indexes: { - faction: "faction", - email: "email", + email: "email", // unique lookup + }, + roles: { + faction: "faction", // selection alias, many players share one }, }); ``` +`indexes` are unique (variants:ruling:unique-indexes; C# `[CultIndex]`, Rust `#[cultcache(index)]`). Within one type +and one alias no two records hold one non-blank value, judged on the result of every write and of every load; a +refusal reads `Records and () both hold unique index ''; an index value names one record.` +(keys in ordinal order, never the value), and writes nothing. The contract is the verdict, which stores and writes are +refused; the text is informational. On a load TS judges record by record in file order and names the first collision +it meets, where C# judges the whole store, so the two runtimes can name different pairs of one store. A blank value (absent, empty, or only whitespace) is +never indexed and never conflicts. Two types may share an alias and a value. Index values are compared as the C# +reference renders them: strings as is (never trimmed), `True`/`False`, integers as digits, a non-integral number as +its exact decimal expansion; NaN and infinities have no value. `roles` name selection and reference aliases (the +member's catalog `indexAlias`, so the catalog bytes are the same either way); they imply nothing about uniqueness and +are never indexed for lookup, so `getByIndex` on a role answers `undefined`. An alias is declared in one of the two. + +### Migrating an existing `indexes` key + +Apply per key, and do it BEFORE the CultLib checkout your tools load is rebuilt or updated. A consumer that loads +CultLib at run time (Bifrost reads `$VOIDBOT_CULTLIB_ROOT`, else the sibling `../CultLib` dist) has no pin to hold: its +next start after the checkout moves is the bump. A consumer that pins a version applies this before it bumps the pin. +Moving an `indexes` key to `roles` is safe on the old CultLib too when the definition lists `members` explicitly (an old +codec definition without `members` derives `indexAlias` from `indexes` only, so a moved key would lose its catalog alias), +so the consumer edit lands first: + +1. Nothing looks records up by the key (`getByIndex`, `getKeyByIndex`, `getIdByIndex`; Python `get_by_index`, + `get_key_by_index`, `node.get_by_index`, `watch_by_index`; Rust `get_by_index`): it is a role. Move it to `roles`; + where a definition lists `members` explicitly, delete it from `indexes` and keep the member's `indexAlias` (the + catalog is the same). +2. Something looks it up: it stays unique only when two live records can never hold one non-blank value, because the + record key is derived from that value alone, or the value is an outside identity (email, sku, external id). + Enums, statuses, kinds, categories, parent or foreign ids, shared hashes and timestamps are roles; a lookup on + one already answered an arbitrary record, so move it to a selection (`any_of`) or `getAll` plus a filter. +3. Before the checkout moves, open each real `.cc` once with the new definitions. A refusal names both keys and the alias and + writes nothing. + +Misclassifying costs differently each way. A role declared unique: the second record holding a shared value is +refused at commit, and a store already holding one refuses to open, so that store is down until the key is +reclassified (loud, names both keys and the alias). A unique key declared a role: lookups by it answer nothing and +duplicates stop being refused. + +The Bifrost TS stores (at 781e6ea) call no lookup anywhere, so by rule 1 every key is a role and no key becomes unique. +All six definitions list `members` explicitly, each with its `indexAlias`, so the edit is deleting each `indexes` block: +the catalog is unchanged and the edit is safe on the CultLib it loads today. It must land before that checkout is +updated: + +| Definition | Keys | Verdict | +|---|---|---| +| `update-request` (`tools/agent-transport.mjs`) | `targetRepoName`, `targetAgentIdentity`, `status` | role: many requests per repo and agent; status is an enum | +| `crossing_receipt` (`tools/bifrost-crossing-documents.mjs`) | `commandId`, `crossingKind`, `status` | role: receipts are `crossing__`, several per command; the rest are enums | +| release authority (`tools/bifrost-repository-release-authority.mjs`) | `repositoryFullName`, `upstreamRef`, `commitSha`, `status` | role: the authority id is derived from repo, ref and sha, so one sha under two refs is two records | +| `topic` (`tools/governance-threads.mjs`) | `jurisdictionRepoName`, `jurisdictionAgentIdentity`, `status` | role: many topics per jurisdiction | +| `comment` (`tools/governance-threads.mjs`) | `topicId`, `authorId`, `stance` | role: many comments per topic; stance is an enum | +| `advertisement` (`tools/provider-advertisement.mjs`) | `serviceName` | role: one record holds it today, but nothing looks it up | + You can also register them later if you feel like changing the machine after boot: ```ts await cache.registerNameLookup(playerDocument, "displayName"); -await cache.registerIndex(playerDocument, "faction", "faction"); +await cache.registerIndex(playerDocument, "email", "email"); ``` Registration, `addBackingStore`, `pullAllBackingStores` and every write and diff --git a/packages/cultcache-ts/src/cult-cache.ts b/packages/cultcache-ts/src/cult-cache.ts index 88ab94e9..775118d4 100644 --- a/packages/cultcache-ts/src/cult-cache.ts +++ b/packages/cultcache-ts/src/cult-cache.ts @@ -224,7 +224,7 @@ export class CultCache { return this.#serial(() => { const registered = this.#requireDefinition(definition); const nameAccessor = this.#compileAccessor(definition.type, "name", accessor); - const derived = this.#deriveTypeLookups(definition.type, nameAccessor, registered.indexAccessors); + const derived = this.#deriveTypeLookups(definition.type, registered.catalogEntry.schemaName, nameAccessor, registered.indexAccessors); this.#installAccessors(registered, nameAccessor, registered.indexAccessors, derived); return definition; }); @@ -243,7 +243,7 @@ export class CultCache { const registered = this.#requireDefinition(definition); const indexAccessors = new Map(registered.indexAccessors); indexAccessors.set(indexName, this.#compileAccessor(definition.type, `index "${indexName}"`, accessor)); - const derived = this.#deriveTypeLookups(definition.type, registered.nameAccessor, indexAccessors); + const derived = this.#deriveTypeLookups(definition.type, registered.catalogEntry.schemaName, registered.nameAccessor, indexAccessors); this.#installAccessors(registered, registered.nameAccessor, indexAccessors, derived); return definition; }); @@ -418,6 +418,7 @@ export class CultCache { return key ? this.get(definition, key) : undefined; } + // A unique index: one key per non-blank value. A roles alias is never indexed and answers undefined. getKeyByIndex( definition: TDefinition, indexName: string, @@ -579,7 +580,7 @@ export class CultCache { if (existingBySchemaName && existingBySchemaName !== registered) { throw refuse(existingBySchemaName, registered.catalogEntry, `schema name "${registered.catalogEntry.schemaName}"`); } - const derived = this.#deriveTypeLookups(definition.type, nameAccessor, indexAccessors); + const derived = this.#deriveTypeLookups(definition.type, registered.catalogEntry.schemaName, nameAccessor, indexAccessors); // Every check and accessor has run; install. this.#definitions.set(definition.type, registered); @@ -795,11 +796,13 @@ export class CultCache { switch (typeof value) { case "string": - return value; - case "number": + return isBlankIndexText(value) ? undefined : value; case "boolean": + return value ? "True" : "False"; case "bigint": - return String(value); + return value.toString(); + case "number": + return canonicalNumberText(value); default: throw new Error(`CultCache ${label} accessor for type "${type}" produced a non-scalar value.`); } @@ -894,6 +897,8 @@ export class CultCache { } } + const lookups = this.#lookupsFor(registered.nameAccessor, registered.indexAccessors, value); + this.#refuseIndexSharing(state, entry.type, registered.catalogEntry.schemaName, entry.key, lookups); return { ...entry, payload: this.#cloneBytes(entry.payload), @@ -902,10 +907,22 @@ export class CultCache { schemaId: registered.catalogEntry.schemaId, catalogEntry: registered.catalogEntry, value, - lookups: this.#lookupsFor(registered.nameAccessor, registered.indexAccessors, value), + lookups, }; } + // The one index rule (variants:ruling:unique-indexes): within one type and one unique alias, no two records hold one + // non-blank value. The held key at the landing record's own key is its previous self and keeps its value. The message + // names both keys, ordinal, and the alias, never the value. + #refuseIndexSharing(state: HydratedState, type: string, schemaName: string, key: string, lookups: EntryLookups): void { + for (const alias of [...lookups.indexes.keys()].sort(compareOrdinal)) { + const held = state.indexToKeyMaps.get(this.#indexId(type, alias))?.get(lookups.indexes.get(alias)!); + if (held !== undefined && held !== key) { + throw indexSharingRefusal(held, key, schemaName, alias); + } + } + } + // Infallible half of admission: replaces any held record at the same key and installs the admitted one. #installEntry(state: HydratedState, registered: RegisteredDefinition, hydrated: HydratedEntry): void { const entryId = this.#entryId(hydrated.type, hydrated.key); @@ -966,12 +983,14 @@ export class CultCache { // Runs the candidate accessors over every held record of the type into local maps. #deriveTypeLookups( type: string, + schemaName: string, nameAccessor: Accessor | undefined, indexAccessors: ReadonlyMap, ): DerivedTypeLookups { const entryLookups: Array<[HydratedEntry, EntryLookups]> = []; const nameMap = nameAccessor ? new Map() : undefined; const indexMaps = new Map>(); + const sharing = new Map(); for (const indexName of indexAccessors.keys()) { indexMaps.set(indexName, new Map()); } @@ -983,10 +1002,23 @@ export class CultCache { nameMap?.set(lookups.name, entry.key); } for (const [indexName, indexValue] of lookups.indexes) { - indexMaps.get(indexName)?.set(indexValue, entry.key); + const map = indexMaps.get(indexName)!; + const holder = map.get(indexValue); + if (holder === undefined) { + map.set(indexValue, entry.key); + } else { + sharing.set(`${indexName}\u0000${indexValue}`, [...(sharing.get(`${indexName}\u0000${indexValue}`) ?? [holder]), entry.key]); + } } } + // Judged whole: any value held by two records refuses, naming the two smallest of its keys. Held records already + // passed the judge for every alias installed before, so only the alias being registered can be shared here. + for (const [id, keys] of sharing) { + keys.sort(compareOrdinal); + throw indexSharingRefusal(keys[0], keys[1], schemaName, id.slice(0, id.indexOf("\u0000"))); + } + return { entryLookups, nameMap, indexMaps }; } @@ -1057,3 +1089,42 @@ export class CultCache { return documentPayloadFromBytes(new Uint8Array(documentPayloadBytes(payload))); } } + +function compareOrdinal(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} + +function indexSharingRefusal(keyA: string, keyB: string, schemaName: string, alias: string): Error { + const [first, second] = compareOrdinal(keyA, keyB) <= 0 ? [keyA, keyB] : [keyB, keyA]; + return new Error(`Records ${first} and ${second} (${schemaName}) both hold unique index '${alias}'; an index value names one record.`); +} + +// Blank is no value: empty or only C# White_Space characters. Not JavaScript's \s, which counts U+FEFF and omits U+0085. +const BLANK_INDEX_TEXT = /^[\t-\r \u0085\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000]*$/; + +function isBlankIndexText(value: string): boolean { + return BLANK_INDEX_TEXT.test(value); +} + +// The canonical text of a number, as C# RenderCanonicalNumber: integers as their digits (-0 is 0), a non-integral finite +// double as its exact decimal expansion, NaN and infinities as no value. +function canonicalNumberText(value: number): string | undefined { + if (!Number.isFinite(value)) { + return undefined; + } + if (Number.isInteger(value)) { + return BigInt(value).toString(); + } + + // value = mantissa * 2^exponent with exponent < 0, so value = mantissa * 5^k / 10^k for k = -exponent. + const view = new DataView(new ArrayBuffer(8)); + view.setFloat64(0, value); + const bits = view.getBigUint64(0); + const biasedExponent = Number((bits >> 52n) & 0x7ffn); + const fraction = bits & 0xfffffffffffffn; + const mantissa = biasedExponent === 0 ? fraction : fraction | (1n << 52n); + const k = -((biasedExponent === 0 ? 1 : biasedExponent) - 1075); + const digits = (mantissa * 5n ** BigInt(k)).toString().padStart(k + 1, "0"); + const text = `${digits.slice(0, digits.length - k)}.${digits.slice(digits.length - k)}`.replace(/0+$/, "").replace(/\.$/, ""); + return value < 0 ? `-${text}` : text; +} diff --git a/packages/cultcache-ts/src/document.ts b/packages/cultcache-ts/src/document.ts index 99ec170c..bc4ace88 100644 --- a/packages/cultcache-ts/src/document.ts +++ b/packages/cultcache-ts/src/document.ts @@ -32,23 +32,22 @@ export function defineDocumentType( throw new Error(`CultCache document type "${definition.type}" declares an invalid name accessor.`); } - if (Array.isArray(definition.indexes)) { - const seen = new Set(); - for (const index of definition.indexes) { - if (!index.name || index.name.trim().length === 0) { - throw new Error(`CultCache document type "${definition.type}" declares an index with an empty name.`); - } - if (seen.has(index.name)) { - throw new Error(`CultCache document type "${definition.type}" declares duplicate index "${index.name}".`); - } - seen.add(index.name); - } - } else if (definition.indexes) { - for (const name of Object.keys(definition.indexes)) { + const declared = new Set(); + for (const [kind, set] of [["index", definition.indexes], ["role", definition.roles]] as const) { + const names = new Set(); + for (const name of Array.isArray(set) ? set.map((entry) => entry.name) : set ? Object.keys(set) : []) { if (!name || name.trim().length === 0) { - throw new Error(`CultCache document type "${definition.type}" declares an index with an empty name.`); + throw new Error(`CultCache document type "${definition.type}" declares ${kind === "index" ? "an index" : "a role"} with an empty name.`); } + if (names.has(name)) { + throw new Error(`CultCache document type "${definition.type}" declares duplicate ${kind} "${name}".`); + } + if (kind === "role" && declared.has(name)) { + throw new Error(`CultCache document type "${definition.type}" declares "${name}" as both an index and a role; an index already names its role.`); + } + names.add(name); } + for (const name of names) declared.add(name); } if (definition.members) { @@ -138,10 +137,13 @@ export function resolveDocumentMembers(definition: AnyCultCacheDocumentDefinitio return definition.members ?? []; } const codec = definition.codec; - const indexAlias = (slotName: string) => - !Array.isArray(definition.indexes) && definition.indexes - ? Object.entries(definition.indexes).find(([, accessor]) => accessor === slotName)?.[0] ?? null - : null; + const indexAlias = (slotName: string) => { + for (const set of [definition.indexes, definition.roles]) { + const alias = !Array.isArray(set) && set ? Object.entries(set).find(([, accessor]) => accessor === slotName)?.[0] : undefined; + if (alias !== undefined) return alias; + } + return null; + }; return codec.members.map((member, index) => ({ ...member, isName: (definition.name as unknown) === codec.names[index], diff --git a/packages/cultcache-ts/src/types.ts b/packages/cultcache-ts/src/types.ts index 88c20f2e..6f6c0923 100644 --- a/packages/cultcache-ts/src/types.ts +++ b/packages/cultcache-ts/src/types.ts @@ -59,9 +59,21 @@ export interface CultCacheDocumentDefinition>; + /** + * Unique lookup aliases. A non-blank value names one record of the type: a write that would leave two records holding + * one value, and a store that already holds two, is refused naming both keys and the alias. Blank values (absent, + * empty, or only whitespace) are never indexed. `getByIndex` and `getKeyByIndex` answer from these. + */ indexes?: | Readonly>>> | readonly CultCacheDocumentIndexDefinition>[]; + /** + * Selection and reference aliases: the member's catalog `indexAlias`. A role implies nothing about uniqueness, many + * records may share a value, and it is never indexed for lookup. An alias is declared in `indexes` or `roles`, not both. + */ + roles?: + | Readonly>>> + | readonly CultCacheDocumentIndexDefinition>[]; formatter?: CultCacheDocumentFormatter>; codec?: SlotsCodec & object>; } diff --git a/packages/cultcache-ts/test/first-call.test.ts b/packages/cultcache-ts/test/first-call.test.ts index 3c0e22e5..45f6edd2 100644 --- a/packages/cultcache-ts/test/first-call.test.ts +++ b/packages/cultcache-ts/test/first-call.test.ts @@ -24,7 +24,6 @@ const itemDocument = defineDocumentType({ schemaVersion: "gamecult.first_call_item.v1", schema: z.object({ name: z.string(), category: z.string(), value: z.number().int() }), name: "name", - indexes: { category: "category" }, }); const registry = defineDocumentRegistry(itemDocument); diff --git a/packages/cultcache-ts/test/unique-index.test.ts b/packages/cultcache-ts/test/unique-index.test.ts new file mode 100644 index 00000000..67af97fb --- /dev/null +++ b/packages/cultcache-ts/test/unique-index.test.ts @@ -0,0 +1,504 @@ +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import { copyFile, mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { test } from "node:test"; + +import { decode } from "@msgpack/msgpack"; +import { z } from "zod"; + +import { codecs } from "../src"; +import { CultCache } from "../src/cult-cache"; +import { defineDocumentType, resolveDocumentMembers } from "../src/document"; +import { SingleFileMessagePackBackingStore } from "../src/single-file-messagepack-backing-store"; +import type { AnyCultCacheDocumentDefinition } from "../src/types"; + +// variants:ruling:unique-indexes. `indexes` are unique within one type, `roles` are selection aliases. The vectors in +// tests/vectors/unique-index are written by the C# reference; the rest of the behaviour is pinned here. + +function findVectors(from: string): string { + for (let dir = from; ; dir = resolve(dir, "..")) { + if (existsSync(join(dir, "tests", "vectors", "unique-index"))) return join(dir, "tests", "vectors", "unique-index"); + if (resolve(dir, "..") === dir) throw new Error("tests/vectors/unique-index not found above the test."); + } +} +const vectors = findVectors(__dirname); + +const CANARY = "CANARY-uidx-5e1b"; + +const skuSchema = z.object({ sku: z.any().optional(), group: z.string().optional() }); + +function item(type: string, where: "indexes" | "roles" | "none" = "indexes", schemaName = `tests.${type}`) { + return defineDocumentType({ + type, + schemaId: `${schemaName}.v1`, + schemaName, + schemaVersion: `${schemaName}.v1`, + schema: skuSchema, + ...(where === "none" ? {} : { [where]: { sku: "sku" } }), + }); +} + +// A definition whose payload is never read back, so a value of any scalar kind (a bigint included) can be held and indexed. +function scalar(type: string) { + const held: unknown[] = []; + return defineDocumentType({ + type, + schemaId: `tests.${type}.v1`, + schemaName: `tests.${type}`, + schemaVersion: `tests.${type}.v1`, + schema: skuSchema, + formatter: { + encode: (value: unknown) => new Uint8Array([held.push(value) - 1]), + decode: (payload: Uint8Array) => held[payload[0]] as never, + }, + indexes: { sku: "sku" }, + }); +} + +function memory(...definitions: AnyCultCacheDocumentDefinition[]) { + let builder = CultCache.builder(); + for (const definition of definitions) builder = builder.withDocumentType(definition); + return builder.build(); +} + +function refusal(keyA: string, keyB: string, schemaName: string, alias: string) { + return `Records ${keyA} and ${keyB} (${schemaName}) both hold unique index '${alias}'; an index value names one record.`; +} + +test("a put of a held value at another key is refused naming both keys and the alias, and nothing moves", async () => { + const dir = await mkdtemp(join(tmpdir(), "uidx-")); + try { + const file = join(dir, "items.cc"); + const document = item("item"); + const cache = CultCache.builder().withDocumentType(document).withGenericStore(new SingleFileMessagePackBackingStore(file)).build(); + await cache.pullAllBackingStores(); + await cache.put(document, "zeta", { sku: CANARY }); + const before = await readFile(file); + const snapshot = cache.snapshot(); + + await assert.rejects(cache.put(document, "alpha", { sku: CANARY }), (error: Error) => { + assert.equal(error.message, refusal("alpha", "zeta", "tests.item", "sku")); + assert.ok(!error.message.includes(CANARY), "the value is never echoed"); + return true; + }); + assert.deepEqual(await readFile(file), before, "the store is byte-identical"); + assert.deepEqual(cache.snapshot(), snapshot); + assert.equal(cache.get(document, "alpha"), undefined); + assert.equal(cache.getKeyByIndex(document, "sku", CANARY), "zeta"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("rewriting a record's own key with its own value lands", async () => { + const document = item("item"); + const cache = memory(document); + await cache.put(document, "a", { sku: "S", group: "one" }); + await cache.put(document, "a", { sku: "S", group: "two" }); + assert.equal(cache.getKeyByIndex(document, "sku", "S"), "a"); + assert.equal(cache.get(document, "a")?.group, "two"); +}); + +test("a removed or changed holder releases its value", async () => { + const document = item("item"); + const cache = memory(document); + await cache.put(document, "b", { sku: "S" }); + await assert.rejects(cache.put(document, "a", { sku: "S" }), /Records a and b/u); + assert.equal(cache.getKeyByIndex(document, "sku", "S"), "b", "the refused write did not take the value"); + + await cache.delete(document, "b"); + assert.equal(cache.getKeyByIndex(document, "sku", "S"), undefined); + await cache.put(document, "a", { sku: "S" }); + assert.equal(cache.getKeyByIndex(document, "sku", "S"), "a"); + + await cache.put(document, "a", { sku: "T" }); + await cache.put(document, "b", { sku: "S" }); + assert.equal(cache.getKeyByIndex(document, "sku", "S"), "b"); + assert.equal(cache.getKeyByIndex(document, "sku", "T"), "a"); +}); + +test("the keys in a refusal are in ordinal order whichever one lands second", async () => { + const document = item("item"); + const cache = memory(document); + await cache.put(document, "m", { sku: "S" }); + await assert.rejects(cache.put(document, "z", { sku: "S" }), { message: refusal("m", "z", "tests.item", "sku") }); + await assert.rejects(cache.put(document, "B", { sku: "S" }), { message: refusal("B", "m", "tests.item", "sku") }); +}); + +// The C# White_Space set, and nothing else, is blank. Every UTF-16 code unit is judged, so a rule written for a few +// characters (or for JavaScript's \s, which counts U+FEFF and omits U+0085) cannot pass. +const CSHARP_WHITE_SPACE = (code: number) => + (code >= 0x09 && code <= 0x0d) || code === 0x20 || code === 0x85 || code === 0xa0 || code === 0x1680 || + (code >= 0x2000 && code <= 0x200a) || code === 0x2028 || code === 0x2029 || code === 0x202f || code === 0x205f || code === 0x3000; + +test("blank is exactly the C# White_Space set: every code unit, alone, repeated and padded", async () => { + const document = item("item"); + const cache = memory(document); + const disagreements: string[] = []; + for (let code = 0; code <= 0xffff; code++) { + if (code >= 0xd800 && code <= 0xdfff) continue; // lone surrogates do not survive MessagePack text + const character = String.fromCharCode(code); + await cache.put(document, "x", { sku: character }); + let blank = true; + try { + await cache.put(document, "y", { sku: character }); + } catch { + blank = false; + } + if (blank !== CSHARP_WHITE_SPACE(code)) disagreements.push(code.toString(16)); + await cache.delete(document, "x"); + await cache.delete(document, "y"); + } + assert.deepEqual(disagreements, []); + + // Not trimmed: a padded value is the value, distinct from its trimmed form. + await cache.put(document, "x", { sku: " a " }); + await cache.put(document, "y", { sku: "a" }); + assert.equal(cache.getKeyByIndex(document, "sku", " a "), "x"); + assert.equal(cache.getKeyByIndex(document, "sku", "a"), "y"); + await assert.rejects(cache.put(document, "z", { sku: " a " }), /Records x and z/u); +}); + +test("blank values are never indexed and never conflict: empty, spaces, absent", async () => { + const document = item("item"); + const cache = memory(document); + for (const [key, sku] of [["a", ""], ["b", " "], ["c", "\u0085"], ["d", undefined], ["e", " "]] as const) { + await cache.put(document, key, { sku }); + } + assert.equal(cache.getKeyByIndex(document, "sku", ""), undefined); + assert.equal(cache.getKeyByIndex(document, "sku", " "), undefined); + // U+FEFF and U+001F are values. + await cache.put(document, "f", { sku: "" }); + await assert.rejects(cache.put(document, "g", { sku: "" }), /Records f and g/u); + await cache.put(document, "h", { sku: "\u001F" }); + await assert.rejects(cache.put(document, "i", { sku: "\u001F" }), /Records h and i/u); +}); + +test("two types may share an alias and a value", async () => { + const first = item("first"); + const second = item("second"); + const cache = memory(first, second); + await cache.put(first, "a", { sku: "S" }); + await cache.put(second, "b", { sku: "S" }); + assert.equal(cache.getKeyByIndex(first, "sku", "S"), "a"); + assert.equal(cache.getKeyByIndex(second, "sku", "S"), "b"); +}); + +test("roles may share values and are not looked up", async () => { + const document = item("item", "roles"); + const cache = memory(document); + await cache.put(document, "a", { sku: "S" }); + await cache.put(document, "b", { sku: "S" }); + await cache.put(document, "c", { sku: "S" }); + assert.equal(cache.getKeyByIndex(document, "sku", "S"), undefined); + assert.equal(cache.getByIndex(document, "sku", "S"), undefined); +}); + +test("an alias declared in indexes and roles is refused at definition; each map is checked like indexes", () => { + const base = { type: "bad", schema: skuSchema, schemaId: "tests.bad.v1", schemaName: "tests.bad", schemaVersion: "tests.bad.v1" }; + assert.throws( + () => defineDocumentType({ ...base, indexes: { sku: "sku" }, roles: { sku: "sku" } }), + { message: 'CultCache document type "bad" declares "sku" as both an index and a role; an index already names its role.' }, + ); + assert.throws(() => defineDocumentType({ ...base, roles: { " ": "sku" } }), /declares a role with an empty name/u); + assert.throws( + () => defineDocumentType({ ...base, roles: [{ name: "r", accessor: "sku" }, { name: "r", accessor: "group" }] }), + /declares duplicate role "r"/u, + ); + assert.doesNotThrow(() => defineDocumentType({ ...base, indexes: { sku: "sku" }, roles: { group: "group" } })); +}); + +test("a codec definition's catalog entry is identical with an alias under indexes or roles", async () => { + const codec = codecs.slots<{ sku: string; group: string }>({ 0: ["sku", codecs.string], 1: ["group", codecs.string] }, "T.Item"); + const make = (where: "indexes" | "roles") => + defineDocumentType({ + type: "codec_item", + schemaId: "tests.codec_item.v1", + schemaName: "tests.codec_item", + schemaVersion: "tests.codec_item.v1", + schema: z.object({ sku: z.string(), group: z.string() }), + codec, + [where]: { sku: "sku" }, + }); + const catalog = async (definition: AnyCultCacheDocumentDefinition) => { + const cache = memory(definition); + await cache.put(definition, "a", { sku: "S", group: "g" } as never); + return cache.snapshot()[0].catalogEntry!; + }; + const members = (definition: AnyCultCacheDocumentDefinition) => resolveDocumentMembers(definition); + assert.deepEqual(members(make("indexes")), members(make("roles"))); + assert.equal(members(make("roles")).find((member) => member.memberName === "Sku")?.indexAlias, "sku"); + const [unique, role] = [await catalog(make("indexes")), await catalog(make("roles"))]; + assert.equal(unique.canonicalSchemaJson, role.canonicalSchemaJson); + assert.equal(unique.contentHash, role.contentHash); + assert.equal(unique.schemaId, role.schemaId); +}); + +test("vectors values.msgpack: every row's canonical text, blank rows no value", async () => { + const rows = decode(await readFile(join(vectors, "values.msgpack")), { useBigInt64: true }) as [string, string, unknown, string | null][]; + assert.ok(rows.length >= 20); + for (const [label, kind, value, text] of rows) { + const document = scalar(`value_${label.replaceAll(/[^a-z0-9]/gu, "_")}`); + const cache = memory(document); + void kind; + const normalized = value; + await cache.put(document, "one", { sku: normalized }); + if (text === null) { + await cache.put(document, "two", { sku: normalized }); + assert.equal(cache.get(document, "two") !== undefined, true, `${label}: a blank value commits twice`); + for (const probe of ["", " ", "NaN", "0", "Infinity", "-Infinity", "null", "undefined"]) { + assert.equal(cache.getKeyByIndex(document, "sku", probe), undefined, `${label}: found by '${probe}'`); + } + } else { + assert.equal(cache.getKeyByIndex(document, "sku", text), "one", `${label}: found by its text`); + await assert.rejects(cache.put(document, "two", { sku: normalized }), /Records one and two/u, label); + } + } +}); + +// An independent oracle for the double rule: the text must denote exactly the double, so N / 10^d == mantissa * 2^exponent +// in exact integer arithmetic, with no trailing zero and no bare point. A rule written to a few spellings cannot pass. +function exactlyDenotes(text: string, value: number): boolean { + const match = /^(-?)(\d+)(?:\.(\d*[1-9]))?$/u.exec(text); + if (!match) return false; + const decimals = match[3]?.length ?? 0; + const numerator = BigInt(match[2] + (match[3] ?? "")); + const view = new DataView(new ArrayBuffer(8)); + view.setFloat64(0, Math.abs(value)); + const bits = view.getBigUint64(0); + const biased = Number((bits >> 52n) & 0x7ffn); + const mantissa = biased === 0 ? bits & 0xfffffffffffffn : (bits & 0xfffffffffffffn) | (1n << 52n); + const exponent = (biased === 0 ? 1 : biased) - 1075; + const lhs = numerator * (exponent >= 0 ? 1n : 2n ** BigInt(-exponent)); + const rhs = mantissa * (exponent >= 0 ? 2n ** BigInt(exponent) : 1n) * 10n ** BigInt(decimals); + return (match[1] === "-") === (value < 0) && lhs === rhs; +} + +test("a number's index text is its exact value: integers as digits, doubles as the exact expansion, NaN and infinities none", async () => { + const document = scalar("number"); + const cache = memory(document); + const probes = [0.1, -0.1, 2.5e-7, 1.5, -123.456, 5e-324, 2.2250738585072014e-308, 1e-10, 123456789.123456789, Number.MIN_VALUE * 3, + ...Array.from({ length: 200 }, (_, i) => Math.sin(i + 1) * 10 ** ((i % 40) - 20)), 1e21 + 2 ** 20 + 0.5 * 0]; + // Round trip through lookups: the exact expansion of each probe is found, and it denotes the probe exactly. + for (const value of probes) { + if (Number.isInteger(value)) continue; + await cache.put(document, "k", { sku: value }); + const candidates = candidateTexts(value); + const found = candidates.filter((candidate) => cache.getKeyByIndex(document, "sku", candidate) === "k"); + assert.equal(found.length, 1, `${value}: exactly one spelling is its index text`); + assert.ok(exactlyDenotes(found[0], value), `${value}: ${found[0]} denotes it exactly`); + } + + await cache.put(document, "k", { sku: 1e21 }); + assert.equal(cache.getKeyByIndex(document, "sku", "1000000000000000000000"), "k"); + await cache.put(document, "k", { sku: -0 }); + assert.equal(cache.getKeyByIndex(document, "sku", "0"), "k"); + await cache.put(document, "k", { sku: 12n }); + assert.equal(cache.getKeyByIndex(document, "sku", "12"), "k"); + await cache.put(document, "k", { sku: true }); + assert.equal(cache.getKeyByIndex(document, "sku", "True"), "k"); + await cache.put(document, "k", { sku: false }); + assert.equal(cache.getKeyByIndex(document, "sku", "False"), "k"); + for (const value of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) { + await cache.put(document, "p", { sku: value }); + await cache.put(document, "q", { sku: value }); + } +}); + +// Spellings a lookup could be probed with: the exact decimal expansion computed by an arbitrary-precision route. +function candidateTexts(value: number): string[] { + const view = new DataView(new ArrayBuffer(8)); + view.setFloat64(0, Math.abs(value)); + const bits = view.getBigUint64(0); + const biased = Number((bits >> 52n) & 0x7ffn); + const mantissa = biased === 0 ? bits & 0xfffffffffffffn : (bits & 0xfffffffffffffn) | (1n << 52n); + const k = 1075 - (biased === 0 ? 1 : biased); + const scaled = (mantissa * 5n ** BigInt(k)).toString().padStart(k + 1, "0"); + const point = scaled.length - k; + const text = `${scaled.slice(0, point)}.${scaled.slice(point)}`.replace(/0+$/u, "").replace(/\.$/u, ""); + return [(value < 0 ? "-" : "") + text, String(value), value.toString()].filter((spelling, i, all) => all.indexOf(spelling) === i); +} + +function twoDefinitions(schemaId: string, where: "indexes" | "roles") { + return defineDocumentType({ + type: "unique_item", + schemaId, + schemaName: "tests.unique_item", + schemaVersion: "tests.unique_item.v1", + schema: z.object({ sku: z.string(), category: z.string() }), + codec: codecs.slots<{ sku: string; category: string }>({ 0: ["sku", codecs.string], 1: ["category", codecs.string] }, "UqVecItem"), + [where]: { Sku: "sku" }, + }); +} + +test("vectors duplicate-store.cc and blank-store.cc hold their verdicts", async () => { + const outcomes = decode(await readFile(join(vectors, "outcomes.msgpack"))) as [string, string | null, string, string | null, string | null][]; + assert.equal(outcomes.length, 3); + const dir = await mkdtemp(join(tmpdir(), "uidx-vec-")); + try { + for (const [name, alias, outcome, key1, key2] of outcomes) { + const file = join(dir, `${outcomes.indexOf(outcomes.find((row) => row[0] === name && row[1] === alias)!)}-${name}`); + await copyFile(join(vectors, name), file); + const before = await readFile(file); + const [envelope] = await new SingleFileMessagePackBackingStore(file).pullAll(); + const document = twoDefinitions(envelope.schemaId!, alias === null ? "roles" : "indexes"); + const cache = CultCache.builder().withDocumentType(document).withGenericStore(new SingleFileMessagePackBackingStore(file)).build(); + const stateBefore = cache.snapshot(); + if (outcome === "open") { + await cache.pullAllBackingStores(); + assert.equal(cache.snapshot().length, name === "blank-store.cc" ? 2 : 4); + if (alias === null) assert.equal(cache.getKeyByIndex(document, "Sku", "S-1"), undefined); + } else { + await assert.rejects(cache.pullAllBackingStores(), (error: Error) => { + assert.equal(error.message, refusal(key1!, key2!, "tests.unique_item", alias!)); + assert.ok(!error.message.includes("S-1")); + return true; + }); + assert.deepEqual(cache.snapshot(), stateBefore, "a refused load admits nothing"); + } + assert.deepEqual(await readFile(file), before, `${name} is untouched`); + } + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("a refused load leaves the cache's prior state as it was, and a later good load still works", async () => { + const dir = await mkdtemp(join(tmpdir(), "uidx-load-")); + try { + const bad = join(dir, "bad.cc"); + await copyFile(join(vectors, "duplicate-store.cc"), bad); + const [envelope] = await new SingleFileMessagePackBackingStore(bad).pullAll(); + const document = twoDefinitions(envelope.schemaId!, "indexes"); + const cache = CultCache.builder().withDocumentType(document).withGenericStore(new SingleFileMessagePackBackingStore(bad)).build(); + await assert.rejects(cache.pullAllBackingStores(), /Records item:a and item:b/u); + assert.equal(cache.get(document, "item:a"), undefined); + assert.equal(cache.getKeyByIndex(document, "Sku", "S-1"), undefined); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("registerIndex over held duplicates installs nothing", async () => { + const document = item("item", "roles"); + const cache = memory(document); + await cache.put(document, "a", { sku: "S", group: "g" }); + await cache.put(document, "b", { sku: "S", group: "h" }); + await assert.rejects(cache.registerIndex(document, "sku", "sku"), { message: refusal("a", "b", "tests.item", "sku") }); + assert.equal(cache.getKeyByIndex(document, "sku", "S"), undefined, "no map was installed"); + await cache.put(document, "c", { sku: "S", group: "i" }); + await cache.registerIndex(document, "group", "group"); + assert.equal(cache.getKeyByIndex(document, "group", "h"), "b"); + await assert.rejects(cache.put(document, "d", { sku: "x", group: "h" }), /Records b and d/u); +}); + +test("registerIndex over distinct held values installs the lookup", async () => { + const document = item("item", "none"); + const cache = memory(document); + await cache.put(document, "a", { sku: "S" }); + await cache.registerIndex(document, "sku", "sku"); + assert.equal(cache.getKeyByIndex(document, "sku", "S"), "a"); +}); + +// ---- a type with several unique indexes: the second alias is judged, in alias order, in both judges ---- + +const pairSchema = z.object({ a: z.string().optional(), b: z.string().optional() }); + +function pair(where: { indexes?: Record; roles?: Record }) { + return defineDocumentType({ + type: "pair", + schemaId: "tests.pair.v1", + schemaName: "tests.pair", + schemaVersion: "tests.pair.v1", + schema: pairSchema, + ...where, + }); +} + +// Declared b first, so insertion order is the opposite of alias order. +const BOTH_UNIQUE = { indexes: { b: "b", a: "a" } as Record }; + +test("a collision on the second unique alias is refused, with a value on the first alias that does not collide", async () => { + const document = pair(BOTH_UNIQUE); + const cache = memory(document); + await cache.put(document, "k1", { a: "x1", b: "shared" }); + await assert.rejects(cache.put(document, "k2", { a: "x2", b: "shared" }), { message: refusal("k1", "k2", "tests.pair", "b") }); + await assert.rejects(cache.put(document, "k2", { a: "x1", b: "y" }), { message: refusal("k1", "k2", "tests.pair", "a") }); + assert.equal(cache.get(document, "k2"), undefined); +}); + +test("when two aliases collide the first alias in ordinal order is named, and its keys are in ordinal order", async () => { + const document = pair(BOTH_UNIQUE); + const cache = memory(document); + await cache.put(document, "m", { a: "same-a", b: "same-b" }); + await assert.rejects(cache.put(document, "c", { a: "same-a", b: "same-b" }), { message: refusal("c", "m", "tests.pair", "a") }); + // The colliding holders differ per alias: a is held by m, b by n; alias a is still the one named. + await cache.put(document, "n", { a: "other-a", b: "other-b" }); + await assert.rejects(cache.put(document, "z", { a: "same-a", b: "other-b" }), { message: refusal("m", "z", "tests.pair", "a") }); +}); + +test("a load judges every unique alias: a store collision on the second alias refuses the open", async () => { + const dir = await mkdtemp(join(tmpdir(), "uidx-pair-")); + try { + for (const [name, records, expected] of [ + ["second-only", [["k1", { a: "1", b: "s" }], ["k2", { a: "2", b: "s" }]], refusal("k1", "k2", "tests.pair", "b")], + ["both-aliases", [["m", { a: "s", b: "s" }], ["c", { a: "s", b: "s" }]], refusal("c", "m", "tests.pair", "a")], + ] as const) { + const file = join(dir, `${name}.cc`); + const roles = pair({ roles: { b: "b", a: "a" } }); + const writer = CultCache.builder().withDocumentType(roles).withGenericStore(new SingleFileMessagePackBackingStore(file)).build(); + for (const [key, value] of records) await writer.put(roles, key, value); + const before = await readFile(file); + + const unique = pair(BOTH_UNIQUE); + const reader = CultCache.builder().withDocumentType(unique).withGenericStore(new SingleFileMessagePackBackingStore(file)).build(); + await assert.rejects(reader.pullAllBackingStores(), { message: expected }, name); + assert.deepEqual(reader.snapshot(), [], "a refused load admits nothing"); + assert.deepEqual(await readFile(file), before); + + const rolesReader = CultCache.builder().withDocumentType(roles).withGenericStore(new SingleFileMessagePackBackingStore(file)).build(); + await rolesReader.pullAllBackingStores(); + assert.equal(rolesReader.snapshot().length, 2, `${name}: the same store opens under roles`); + } + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("registerIndex of a second unique alias judges it, naming the two smallest keys whatever order they were written in", async () => { + const document = pair({ indexes: { a: "a" }, roles: { b: "b" } }); + const cache = memory(document); + await cache.put(document, "z", { a: "1", b: "s" }); + await cache.put(document, "m", { a: "2", b: "s" }); + await cache.put(document, "q", { a: "3", b: "s" }); + await assert.rejects(cache.registerIndex(document, "b", "b"), { message: refusal("m", "q", "tests.pair", "b") }); + assert.equal(cache.getKeyByIndex(document, "a", "1"), "z", "the alias already installed is intact"); + assert.equal(cache.getKeyByIndex(document, "b", "s"), undefined); +}); + +test("verdict parity: the same stores are refused whatever the load order, and the first collision refuses (text is informational)", async () => { + const dir = await mkdtemp(join(tmpdir(), "uidx-verdict-")); + try { + // Three records, one pair colliding; whichever position the pair has in the file, the store is refused and a clean store opens. + const roles = pair({ roles: { a: "a" } }); + for (const [name, values, refused] of [ + ["pair-first", ["s", "s", "t"], true], + ["pair-last", ["t", "s", "s"], true], + ["pair-split", ["s", "t", "s"], true], + ["distinct", ["r", "s", "t"], false], + ] as const) { + const file = join(dir, `${name}.cc`); + const writer = CultCache.builder().withDocumentType(roles).withGenericStore(new SingleFileMessagePackBackingStore(file)).build(); + for (const [i, a] of values.entries()) await writer.put(roles, `r${i}`, { a }); + const unique = pair({ indexes: { a: "a" } }); + const reader = CultCache.builder().withDocumentType(unique).withGenericStore(new SingleFileMessagePackBackingStore(file)).build(); + if (refused) await assert.rejects(reader.pullAllBackingStores(), /both hold unique index 'a'/u, name); + else await reader.pullAllBackingStores(); + assert.equal(reader.snapshot().length, refused ? 0 : 3, name); + } + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/cultnet-ts/README.md b/packages/cultnet-ts/README.md index 07996956..7d1b9a6a 100644 --- a/packages/cultnet-ts/README.md +++ b/packages/cultnet-ts/README.md @@ -66,6 +66,7 @@ Current message families: - `cultnet.snapshot_response.v0` - `cultnet.snapshot_response_raw.v0` - `cultnet.snapshot_request.v1`, `cultnet.database_subscribe.v1`, `cultnet.snapshot_response_raw.v1` (typed-selection carriers; no selection engine yet) +- `cultnet.database_change_raw.v0` (optional `commitId` and `commitCount >= 2` mark the changes of one commit; carried and validated here, no TS code groups or applies by them) - `cultnet.sample.change_name.v0` - `cultnet.sample.chat.v0` diff --git a/packages/cultnet-ts/src/contracts.ts b/packages/cultnet-ts/src/contracts.ts index 8dd37ced..b733c959 100644 --- a/packages/cultnet-ts/src/contracts.ts +++ b/packages/cultnet-ts/src/contracts.ts @@ -424,6 +424,10 @@ export interface CultNetDatabaseChangeRawMessage { document?: CultNetRawDocumentRecord; recordKey?: string; schemaId?: string; + /** Opaque, one subscription: changes sharing a commitId are the changes of one commit. */ + commitId?: string | null; + /** How many changes the commit carries, at least two. The C# server sends null on a change outside a grouped commit. */ + commitCount?: number | null; } export interface CultNetSchemaDescriptor { @@ -1397,6 +1401,15 @@ function validateDatabaseChangeRawMessage( ); } + if (candidate.commitId !== undefined && candidate.commitId !== null && + (typeof candidate.commitId !== "string" || candidate.commitId.length === 0)) { + throw new Error("Validation failed for cultnet.database_change_raw.v0: /commitId: must be a non-empty string"); + } + if (candidate.commitCount !== undefined && candidate.commitCount !== null && + (typeof candidate.commitCount !== "number" || !Number.isInteger(candidate.commitCount) || candidate.commitCount < 2)) { + throw new Error("Validation failed for cultnet.database_change_raw.v0: /commitCount: must be an integer of at least 2"); + } + if (candidate.changeKind === "removed") { requireNonEmptyString(candidate.recordKey, "/recordKey"); requireNonEmptyString(candidate.schemaId, "/schemaId"); diff --git a/packages/cultnet-ts/test/cultnet.test.ts b/packages/cultnet-ts/test/cultnet.test.ts index 37d963a8..ac4f539d 100644 --- a/packages/cultnet-ts/test/cultnet.test.ts +++ b/packages/cultnet-ts/test/cultnet.test.ts @@ -54,6 +54,7 @@ import { validateGhostlightAgentStateGenerated, validateGhostlightAgentState, shardServes, + type CultNetDatabaseChangeRawMessage, type CultNetLoginMessage, type GhostlightAgentStateShape, type GhostlightAgentStateDocument, @@ -1688,6 +1689,55 @@ test("database subscription contracts match the C# live document lane", () => { assert.equal(removed.schemaVersion, "cultnet.database_change_raw.v0"); assert.equal(removed.recordKey, "counter:main"); + const grouped = parseCultNetMessage({ + schemaVersion: "cultnet.database_change_raw.v0", + messageId: "change-counter-3", + subscriptionId: "counter:browser-1", + changeKind: "removed", + recordKey: "counter:main", + schemaId: "sample.counter_state.v1", + commitId: "c1", + commitCount: 2, + }); + assert.equal((grouped as CultNetDatabaseChangeRawMessage).commitId, "c1"); + assert.equal((grouped as CultNetDatabaseChangeRawMessage).commitCount, 2); + assert.deepEqual(encodeCultNetMessageForWire(grouped), grouped, "both commit fields travel"); + assert.equal((removed as CultNetDatabaseChangeRawMessage).commitId, undefined, "a change without them parses as before"); + const changeWith = (extra: Record) => () => parseCultNetMessage({ + schemaVersion: "cultnet.database_change_raw.v0", + messageId: "change-counter-4", + subscriptionId: "counter:browser-1", + changeKind: "removed", + recordKey: "counter:main", + schemaId: "sample.counter_state.v1", + ...extra, + }); + // The C# server writes commitId and commitCount as nil on every change outside a grouped commit, so null is absent. + const ungrouped = changeWith({ commitId: null, commitCount: null })() as CultNetDatabaseChangeRawMessage; + assert.equal(ungrouped.commitId, null); + assert.equal(ungrouped.commitCount, null); + assert.doesNotThrow(changeWith({ commitId: null })); + assert.doesNotThrow(changeWith({ commitCount: null })); + assert.doesNotThrow(() => parseCultNetMessage({ + schemaVersion: "cultnet.database_change_raw.v0", + messageId: "change-counter-5", + subscriptionId: "counter:browser-1", + changeKind: "added", + document: { + schemaId: "sample.counter_state.v1", + recordKey: "counter:main", + storedAt: "2026-08-17T00:00:00Z", + payloadEncoding: "messagepack", + payload: new Uint8Array([0x81, 0xa5, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x01]), + }, + recordKey: null, + schemaId: null, + commitId: null, + commitCount: null, + })); + for (const commitId of ["", 5]) assert.throws(changeWith({ commitId }), /\/commitId/, `commitId ${JSON.stringify(commitId)}`); + for (const commitCount of [1, 0, 2.5, "two", -3]) assert.throws(changeWith({ commitCount }), /\/commitCount/, `commitCount ${commitCount}`); + const unsubscribe = parseCultNetMessage({ schemaVersion: "cultnet.database_unsubscribe.v0", messageId: "unsubscribe-counter", diff --git a/tests/GameCult.Caching.Tests/UniqueIndexVectorTests.cs b/tests/GameCult.Caching.Tests/UniqueIndexVectorTests.cs new file mode 100644 index 00000000..d7c68459 --- /dev/null +++ b/tests/GameCult.Caching.Tests/UniqueIndexVectorTests.cs @@ -0,0 +1,294 @@ +#nullable enable +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Reflection; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Caching.Tests +{ + // variants:ruling:unique-indexes. The canonical index text and the unique-index load verdicts in tests/vectors/unique-index. + // The C# reference is the only writer (CULTCACHE_WRITE_VECTORS=1); every other run reads the committed bytes and + // compares them byte for byte. Other runtimes are held to the same text and the same verdicts. + public class UniqueIndexVectorTests + { + private const string WriteVariable = "CULTCACHE_WRITE_VECTORS"; + private static readonly DateTimeOffset StoredAt = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + private static bool Writing => Environment.GetEnvironmentVariable(WriteVariable) == "1"; + + private static string VectorDirectory() + { + for (var dir = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); dir != null; dir = dir.Parent) + { + var vectors = Path.Combine(dir.FullName, "tests", "vectors"); + if (Directory.Exists(vectors)) return Path.Combine(vectors, "unique-index"); + } + + throw new DirectoryNotFoundException($"tests/vectors not found above {TestContext.CurrentContext.TestDirectory}."); + } + + private static string VectorPath(string name) => Path.Combine(VectorDirectory(), name); + + private static void Check(string name, byte[] reference) + { + var path = VectorPath(name); + if (Writing) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllBytes(path, reference); + return; + } + + Assert.That(File.Exists(path), Is.True, $"vector {name} is committed"); + Assert.That(Convert.ToHexString(reference), Is.EqualTo(Convert.ToHexString(File.ReadAllBytes(path))), $"vector {name}"); + } + + private static byte[] Committed(string name) + { + var path = VectorPath(name); + Assert.That(File.Exists(path), Is.True, $"vector {name} is committed"); + return File.ReadAllBytes(path); + } + + // ---- values.msgpack: [label, kind, value, text-or-nil] ---- + + private sealed record Row(string Label, string Kind, object Value, string? Text); + + private static readonly MethodInfo RenderCanonicalNumber = + typeof(CultDocumentRegistry).GetMethod("RenderCanonicalNumber", BindingFlags.NonPublic | BindingFlags.Static)!; + + private static object? ByIndex(CultCache cache, Type type, string alias, string value) => + typeof(CultCache).GetMethod(nameof(CultCache.GetByIndex))!.MakeGenericMethod(type).Invoke(cache, new object[] { alias, value }); + + private static string? Number(object raw) => (string?)RenderCanonicalNumber.Invoke(null, new[] { raw }); + + // Strings and booleans carry the text the rule states; numbers carry whatever the reference renders, never hand-typed. + private static List Rows() => new() + { + new("plain", "string", "S-1", "S-1"), + new("empty", "string", "", null), + new("space", "string", " ", null), + new("nel", "string", "\u0085", null), + new("nbsp", "string", "\u00A0", null), + new("ideographic-space", "string", "\u3000", null), + new("vertical-tab", "string", "\u000B", null), + new("bom", "string", "\uFEFF", "\uFEFF"), + new("unit-separator", "string", "\u001F", "\u001F"), + new("padded", "string", " a ", " a "), + new("int64-max", "int64", long.MaxValue, Number(long.MaxValue)), + new("int64-min", "int64", long.MinValue, Number(long.MinValue)), + new("uint64-max", "uint64", ulong.MaxValue, Number(ulong.MaxValue)), + new("double-zero", "double", 0.0, Number(0.0)), + new("double-negative-zero", "double", -0.0, Number(-0.0)), + new("double-tenth", "double", 0.1, Number(0.1)), + new("double-1e21", "double", 1e21, Number(1e21)), + new("double-2.5e-7", "double", 2.5e-7, Number(2.5e-7)), + new("double-nan", "double", double.NaN, Number(double.NaN)), + new("double-infinity", "double", double.PositiveInfinity, Number(double.PositiveInfinity)), + new("bool-true", "bool", true, "True"), + new("bool-false", "bool", false, "False"), + }; + + private static byte[] ValuesFile() + { + var buffer = new ArrayBufferWriter(); + var writer = new MessagePackWriter(buffer); + var rows = Rows(); + writer.WriteArrayHeader(rows.Count); + foreach (var row in rows) + { + writer.WriteArrayHeader(4); + writer.Write(row.Label); + writer.Write(row.Kind); + switch (row.Value) + { + case string s: writer.Write(s); break; + case long l: writer.Write(l); break; + case ulong u: writer.Write(u); break; + case double d: writer.Write(d); break; + case bool b: writer.Write(b); break; + default: throw new InvalidOperationException(row.Kind); + } + + if (row.Text == null) writer.WriteNil(); else writer.Write(row.Text); + } + + writer.Flush(); + return buffer.WrittenSpan.ToArray(); + } + + private static readonly Dictionary KindTypes = new[] + { + ("string", typeof(string)), ("int64", typeof(long)), ("uint64", typeof(ulong)), ("double", typeof(double)), ("bool", typeof(bool)) + }.ToDictionary(pair => pair.Item1, pair => EmittedDocumentTypes.Emit( + "UqKind" + pair.Item1, "tests.uq_kind_" + pair.Item1, "tests.uq_kind_" + pair.Item1 + ".v1", + new[] { new EmittedDocumentTypes.Field("Value", pair.Item2, 0, Index: "v") })); + + [Test] + public void ValuesFileIsTheReferencesCanonicalText() + { + Check("values.msgpack", ValuesFile()); + + var reader = new MessagePackReader(Committed("values.msgpack")); + var count = reader.ReadArrayHeader(); + var rows = Rows(); + Assert.That(count, Is.EqualTo(rows.Count)); + foreach (var row in rows) + { + Assert.That(reader.ReadArrayHeader(), Is.EqualTo(4)); + Assert.That(reader.ReadString(), Is.EqualTo(row.Label)); + Assert.That(reader.ReadString(), Is.EqualTo(row.Kind)); + reader.Skip(); + var text = reader.TryReadNil() ? null : reader.ReadString(); + Assert.That(text, Is.EqualTo(row.Text), row.Label); + + var type = KindTypes[row.Kind]; + var registry = CultDocumentRegistry.ForTypes(new[] { type }); + using var cache = CultCacheMessagePack.Create(Path.Combine(Path.GetTempPath(), $"cultlib-uidxv-{Guid.NewGuid():N}.cc"), new CultCacheOpenOptions { Registry = registry }); + cache.Commit(batch => + { + batch.Upsert(type, EmittedDocumentTypes.New(type, ("Value", row.Value)), new CultRecordKey("one")); + batch.Upsert(type, EmittedDocumentTypes.New(type, ("Value", row.Value)), new CultRecordKey(text == null ? "two" : "one")); + }); + if (text == null) + { + Assert.That(cache.Get(new CultRecordKey("two")), Is.Not.Null, $"{row.Label}: a blank value commits twice"); + foreach (var probe in new[] { "", " ", "NaN", "0", "Infinity", "∞" }) + Assert.That(ByIndex(cache, type, "v", probe), Is.Null, $"{row.Label}: found by '{probe}'"); + } + else + { + Assert.That(ByIndex(cache, type, "v", text), Is.Not.Null, $"{row.Label}: found by its text"); + } + } + } + + // ---- the stores: written through a role, judged by an index ---- + + private static readonly EmittedDocumentTypes.Field[] RoleFields = + { + new("Sku", typeof(string), 0, Role: "Sku"), new("Category", typeof(string), 1) + }; + + private static readonly EmittedDocumentTypes.Field[] IndexFields = + { + new("Sku", typeof(string), 0, Index: "Sku"), new("Category", typeof(string), 1) + }; + + private static readonly Type ItemRole = EmittedDocumentTypes.Emit("UqVecItemRole", "tests.unique_item", "tests.unique_item.v1", RoleFields); + private static readonly Type ItemIndex = EmittedDocumentTypes.Emit("UqVecItemIndex", "tests.unique_item", "tests.unique_item.v1", IndexFields); + + private static readonly (string Key, string Sku, string Category)[] Items = + { + ("item:a", "S-1", "tools"), ("item:b", "S-1", "tools"), ("item:c", " ", "parts"), ("item:d", "\u0085", "parts") + }; + + private static byte[] StoreBytes(string[] keys) + { + var directory = Path.Combine(Path.GetTempPath(), $"cultlib-uidxs-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + try + { + var path = Path.Combine(directory, "store.cc"); + using (var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions + { + Registry = CultDocumentRegistry.ForTypes(new[] { ItemRole }), StoreFlushOnDispose = true + })) + { + cache.Clock = () => StoredAt; + foreach (var item in Items.Where(item => keys.Contains(item.Key))) + cache.UpsertAsync(ItemRole, EmittedDocumentTypes.New(ItemRole, ("Sku", item.Sku), ("Category", item.Category)), new CultRecordKey(item.Key)).GetAwaiter().GetResult(); + cache.FlushAsync().GetAwaiter().GetResult(); + } + + return File.ReadAllBytes(path); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + private static readonly (string File, string? Alias, string Outcome, string? K1, string? K2)[] Outcomes = + { + ("duplicate-store.cc", "Sku", "refused", "item:a", "item:b"), + ("duplicate-store.cc", null, "open", null, null), + ("blank-store.cc", "Sku", "open", null, null), + }; + + private static byte[] OutcomesFile() + { + var buffer = new ArrayBufferWriter(); + var writer = new MessagePackWriter(buffer); + writer.WriteArrayHeader(Outcomes.Length); + foreach (var row in Outcomes) + { + writer.WriteArrayHeader(5); + writer.Write(row.File); + foreach (var text in new[] { row.Alias, row.Outcome, row.K1, row.K2 }) + if (text == null) writer.WriteNil(); else writer.Write(text); + } + + writer.Flush(); + return buffer.WrittenSpan.ToArray(); + } + + private static string? NextText(ref MessagePackReader reader) => reader.TryReadNil() ? null : reader.ReadString(); + + [Test] + public void StoreVectorsHoldTheirVerdicts() + { + Check("duplicate-store.cc", StoreBytes(new[] { "item:a", "item:b", "item:c", "item:d" })); + Check("blank-store.cc", StoreBytes(new[] { "item:c", "item:d" })); + Check("outcomes.msgpack", OutcomesFile()); + + var reader = new MessagePackReader(Committed("outcomes.msgpack")); + var count = reader.ReadArrayHeader(); + Assert.That(count, Is.EqualTo(Outcomes.Length)); + for (var i = 0; i < count; i++) + { + Assert.That(reader.ReadArrayHeader(), Is.EqualTo(5)); + var file = reader.ReadString()!; + var alias = NextText(ref reader); + var outcome = NextText(ref reader)!; + var k1 = NextText(ref reader); + var k2 = NextText(ref reader); + + var directory = Path.Combine(Path.GetTempPath(), $"cultlib-uidxo-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + try + { + var path = Path.Combine(directory, "store.cc"); + File.WriteAllBytes(path, Committed(file)); + var before = File.ReadAllBytes(path); + var type = alias == null ? ItemRole : ItemIndex; + var registry = CultDocumentRegistry.ForTypes(new[] { type }); + if (outcome == "open") + { + using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = registry }); + Assert.That(cache.Get(new CultRecordKey("item:c")), Is.Not.Null, file); + } + else + { + var refused = Assert.Throws(() => CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = registry }).Dispose())!; + Assert.That(refused.Message, Is.EqualTo( + $"Records {k1} and {k2} (tests.unique_item) both hold unique index '{alias}'; an index value names one record.")); + Assert.That(refused.Message, Does.Not.Contain("S-1")); + } + + Assert.That(File.ReadAllBytes(path), Is.EqualTo(before), $"{file} is untouched"); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + } + } +} diff --git a/tests/vectors/unique-index/README.md b/tests/vectors/unique-index/README.md new file mode 100644 index 00000000..548c8845 --- /dev/null +++ b/tests/vectors/unique-index/README.md @@ -0,0 +1,24 @@ +# unique-index vectors + +Written only by the C# reference, `tests/GameCult.Caching.Tests/UniqueIndexVectorTests.cs`, with +`CULTCACHE_WRITE_VECTORS=1`. Every other run reads these files and compares them byte for byte; a changed vector is a +reviewed commit. Other runtimes are held to the same canonical text and the same verdicts (`variants:ruling:unique-indexes`). + +## Files + +- `values.msgpack`: an array of `[label, kind, value, text-or-nil]`, kind one of `string`, `int64`, `uint64`, `double`, + `bool` (decode integers 64-bit wide). `text` is the canonical index text of a member holding `value`: a record holding + it is found by `GetByIndex(alias, text)`. A nil text is a blank value: no value, never indexed, never judged, found by + no text, and two records holding it commit. Strings are as is (never trimmed), blank being empty or only C# + `White_Space` characters (U+0009-000D, U+0020, U+0085, U+00A0, U+1680, U+2000-200A, U+2028, U+2029, U+202F, U+205F, + U+3000); U+FEFF and U+001F are values. Booleans are `True` and `False`. Integers are their decimal digits. A + non-integral finite double is its exact decimal expansion (written by C# `RenderExactDouble`, never hand-typed), + `-0.0` is `0`, NaN and infinities are nil. +- `duplicate-store.cc`: a single-file store, schema `tests.unique_item` v1, slots `[Sku: string, Category: string]`, + stored-at pinned to `2026-01-01T00:00:00Z`. Records `item:a` and `item:b` both hold Sku `S-1`; `item:c` holds Sku + `" "` and `item:d` holds Sku `U+0085`. Written through a role Sku. +- `blank-store.cc`: the same, only `item:c` and `item:d`. +- `outcomes.msgpack`: rows `[file, unique alias or nil, open|refused, k1 or nil, k2 or nil]`. Opening `file` with that + alias a unique index (nil: a role, no unique alias) yields the verdict. A refusal reads, byte for byte, + `Records and (tests.unique_item) both hold unique index ''; an index value names one record.` + with `k1 < k2` ordinal, never contains the value, and leaves the file byte-identical. diff --git a/tests/vectors/unique-index/blank-store.cc b/tests/vectors/unique-index/blank-store.cc new file mode 100644 index 0000000000000000000000000000000000000000..2b69faccd2c2237747bde2673f25dbcdcf6947bb GIT binary patch literal 964 zcmchV!A{#i5QfXKJO%Z&!CJ5Fjn}uThu(T4T{S{E@&HLI!L=X| zmmXU204U-m;Axn|Ap(UP6l*U#`|tn#v!0*-R8u$wY6pGm1~vNMgYn_@2fG727O4!g zERjrO5;7r~2*3y_G9DTk869$yg^Fv?p^^cjh+<4Y>sYWPHbIi`zYeXlepBV&8r;qu zmKQ4(A})DRq77;Y1&@G5EXriexJaam5Da<8qAQGmE(Rhdtjdh&a>FB)1tHy#H*4j- zXQ`PIK0=9J>aF0s&O-1RYnuEof@PBT;IKcahyOXwBH)8GUq|ERpL^=0OpaciP{)5ByybLyIh|FH*3R$RToqhS zex1!GD^<@H=K2k%vD;(;tu4mV`SMx&y`Ar_UQd2+obEyG>;kV}6u&+>o*uOPTV?V; Ar~m)} literal 0 HcmV?d00001 diff --git a/tests/vectors/unique-index/duplicate-store.cc b/tests/vectors/unique-index/duplicate-store.cc new file mode 100644 index 0000000000000000000000000000000000000000..4caec45135e6bf1ccb182d64fb3ab055e8b40708 GIT binary patch literal 1222 zcmc(d&u-K(5XR{dcnQdrZR9wKH_i=l;KqREv-Pl+AG3iZeXbg4wIP_^oX z1BeHxR=h-c8fG`UE2`xNl*kv4KhO93oO%CM_m$V6_b_z6?J#^D9e%ic==Ol8BA2mJ znPdjDm51Xf6)cx+{2P0X!|H8)^lEh9vcq?my*sbE=Zqb%cJJUZ{fzA0bz_@eY! zf7_~%@OtCbaFZh4|r6w?j)W2b%_ zI0{q4Bd9URgDtyYv=ThU&XsNRI}IU`tj1cACdi*r&#Lu7&PG8qv@Krtr0~=Ig-0=U zlq@WKS2hzWm+moI?6A>vYN2vS&7hfXHXgbO`r;SPrmrfx*cg0szbb)DgTbP7Ly%>g zgVCBK+K@Z|kCV2W{$rdK;1!zZ2Zx(K_otIGJHBy3i9cU?%lZ6bzNjAWoUdbU3oee1 z&K8rE!(W5Byvu3qc3DL031j(kg>3llEqC8<&rXWn=+w7ull`6uzCEfu;8ca&F-d!L^CrE^)ANyMd^WqB&qpt$3} zI&~?ANxsu|_bOdx3Ga)xo^&f+C=s>g)8hw~o~j^A)(tapC_@nsQl4zdzVq*N@ohY= z^ue}BbJ^#Slk>ppUNX8tJZ#J2?CrZc^^1{`xs!>6r#z2S%ifr5=vl(UP=t1(%e#e1 zy)RYnVQ0wil`C|zyKLgfN62+40%?Q+NrCLKK$By_2c2`EzoPcAnczD#yn@i@j zO#$?Ysm$SA>iML0uIKhbT-i;_-^~Rp{jz7Dr SfqK5`Zt^H=+Q;RM8vg+*qAr*K literal 0 HcmV?d00001 From ba25b43369856f5f870ba306ea631c5acfa13594 Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 06:59:08 +0000 Subject: [PATCH 10/93] cultcache-py, cultnet-py: indexes means unique, roles names selection aliases; commitId/commitCount on change messages (#94) --- docs/start/README.md | 5 +- packages/cultcache-py/CHANGELOG.md | 2 + packages/cultcache-py/README.md | 29 +- .../cultcache-py/src/cultcache_py/cache.py | 108 +++- .../src/cultcache_py/documents.py | 19 +- packages/cultcache-py/tests/test_cultcache.py | 6 +- .../cultcache-py/tests/test_first_call.py | 1 - .../cultcache-py/tests/test_unique_index.py | 575 ++++++++++++++++++ packages/cultmesh-py/README.md | 11 +- packages/cultmesh-py/src/cultmesh_py/node.py | 12 +- .../tests/test_unique_index_watch.py | 74 +++ packages/cultnet-py/CHANGELOG.md | 1 + .../src/cultnet_py/cultmesh_contracts.py | 2 +- .../src/cultnet_py/schema_catalog.py | 2 + .../cultnet-py/src/cultnet_py/subscription.py | 16 + packages/cultnet-py/tests/test_cultnet.py | 59 ++ 16 files changed, 892 insertions(+), 30 deletions(-) create mode 100644 packages/cultcache-py/tests/test_unique_index.py create mode 100644 packages/cultmesh-py/tests/test_unique_index_watch.py diff --git a/docs/start/README.md b/docs/start/README.md index e52f7d73..6a7d6d74 100644 --- a/docs/start/README.md +++ b/docs/start/README.md @@ -167,8 +167,8 @@ fn first_call() -> anyhow::Result<()> { ## Python first call -A document is a definition with an encoder and decoder; `name=` and `indexes=` register the name lookup -and indexes. The source is +A document is a definition with an encoder and decoder; `name=` registers the name lookup, `indexes=` unique lookups and +`roles=` many-row selection aliases. The source is [`packages/cultcache-py/tests/test_first_call.py`](../../packages/cultcache-py/tests/test_first_call.py), run by `python -m unittest tests.test_first_call` in `packages/cultcache-py` with `src` on `PYTHONPATH` and `msgpack` installed (the store needs it). A second test fails if the block below stops matching the @@ -194,7 +194,6 @@ item_doc = define_document_type( encode=lambda item: asdict(item), decode=lambda raw: Item(**raw), name="name", - indexes={"category": "category"}, ) diff --git a/packages/cultcache-py/CHANGELOG.md b/packages/cultcache-py/CHANGELOG.md index b460ab05..641cc0ed 100644 --- a/packages/cultcache-py/CHANGELOG.md +++ b/packages/cultcache-py/CHANGELOG.md @@ -9,6 +9,8 @@ ### Breaking +- `indexes` are unique, as C# `[CultIndex]`: a write, load or `register_index` that leaves two records of a type holding one non-blank value under one alias is refused (`CultCacheError`) naming both keys and the alias, and writes nothing. Index text is the C# canonical text (`True`/`False`, integers as digits, floats as the exact expansion, blank values never indexed), so an empty or whitespace string is no longer a value. A new `roles` map names the many-row selection aliases (never indexed for lookup, same catalog `index_alias`). Classify every `indexes` key per the README before updating. The verdict is the contract, the text is informational. + - A single-file store written as `cultcache.store.v3` is read and written: a record that holds an element id (an owned list element with an `[CultElementId]` member, carried through every write) marks the store v3, and a v3 store is carried through a rewrite with its ids. A runtime from before this change refuses a v3 store by name. - A write over a file the reader refuses (`StoreUnreadableError`, from `push`, `push_all` and `delete`) refuses and leaves the file as it is. A flush used to replace such a file. A write set cannot merge with a disk it cannot read, so the file is repaired or removed by its owner before the store writes again. - Added `StoreUnreadableError` (a `ValueError`) for a store file this runtime cannot read, and `SchemaConflictError` (a `ValueError`) for a diff --git a/packages/cultcache-py/README.md b/packages/cultcache-py/README.md index 41c3095c..ab586ff6 100644 --- a/packages/cultcache-py/README.md +++ b/packages/cultcache-py/README.md @@ -73,7 +73,7 @@ store. - `register_document_type(...)` - `register_registry(...)` - `register_name_lookup(...)` -- `register_index(...)` +- `register_index(...)` (a unique index) - `add_backing_store(...)` - `add_generic_store(...)` - `pull_all_backing_stores()` @@ -95,6 +95,33 @@ store. - `delete_global(...)` - `snapshot()` +## Indexes And Roles + +`indexes=` are unique lookups (variants:ruling:unique-indexes; C# `[CultIndex]`): within one document type and one alias +no two records hold one non-blank value, judged on the result of every write (a batch is judged whole, so a swap inside +one `put_envelopes` lands) and of every load and `register_index`. A refusal is a `CultCacheError`, +`Records and () both hold unique index ''; an index value names one record.`, keys in +ordinal order, never the value; it writes nothing, and a refused load or registration installs nothing. The contract is +the verdict; the text is informational. Blank values (absent, empty, or only C# `White_Space` characters) are never +indexed and never conflict. Two types may share an alias and a value. Values compare as the C# canonical text: strings as +is (never trimmed), `True`/`False`, integers as digits, a non-integral float as its exact decimal expansion, NaN and +infinities no value. + +`roles=` name selection aliases (C# `[CultRole]`): many records may share a value, nothing is indexed or refused, +`get_by_index` on a role answers `None`, and the member's catalog `index_alias` is the same either way, so catalog bytes +do not move. An alias is declared in one of the two. `CultMeshDatabase.watch_by_index` reads `indexes` only. + +Migrating an existing `indexes` key, per key, before bumping: + +1. Nothing looks records up by the key (`get_by_index`, `get_key_by_index`, `node.get_by_index`, `watch_by_index`): move it to `roles`. +2. Something looks it up: it stays unique only when two live records can never hold one non-blank value (the record key + is derived from that value alone, or it is an outside identity: email, sku, external id). Enums, statuses, kinds, + categories, parent or foreign ids, shared hashes and timestamps are roles; select them with `get_all` plus a filter. +3. Open each real `.cc` once with the new definitions. A refusal names both keys and the alias and writes nothing. + +Misclassifying: a role declared unique makes a store that holds a shared value refuse to open until the key is +reclassified; a unique key declared a role answers no lookups and stops refusing duplicates. + ## Persistence Model Backing stores persist envelopes, not caller domain objects. The cache decodes payloads through registered document definitions and rejects unknown type discriminators. This keeps Python's dynamic runtime from turning persistence into an open polymorphic sewer with a cheerful docstring. diff --git a/packages/cultcache-py/src/cultcache_py/cache.py b/packages/cultcache-py/src/cultcache_py/cache.py index 8f4ac759..c934c33b 100644 --- a/packages/cultcache-py/src/cultcache_py/cache.py +++ b/packages/cultcache-py/src/cultcache_py/cache.py @@ -1,9 +1,11 @@ from __future__ import annotations import functools +import math import threading +from decimal import Decimal from dataclasses import dataclass, field, replace -from typing import Any, Callable, Generic, TypeVar +from typing import Any, Callable, Generic, Iterable, TypeVar from .backing_store import BackingStore, CultCacheEnvelope from .documents import DocumentDefinition, extract_value @@ -52,6 +54,52 @@ class CultCacheError(RuntimeError): pass +# Blank is no value: empty or only C# White_Space characters (the 25 chars string.IsNullOrWhiteSpace is true for). +# An explicit set, not str.isspace(): Python also counts U+001C-001F, which C# does not, and omits nothing C# counts. +_BLANK_INDEX_CHARS = frozenset( + chr(code) + for code in ( + *range(0x09, 0x0E), + 0x20, + 0x85, + 0xA0, + 0x1680, + *range(0x2000, 0x200B), + 0x2028, + 0x2029, + 0x202F, + 0x205F, + 0x3000, + ) +) + + +def _utf16_order(text: str) -> bytes: + """Sorts as C# StringComparer.Ordinal does: by UTF-16 code unit, not code point.""" + return text.encode("utf-16-be", "surrogatepass") + + +def index_text(value: Any) -> str | None: + """The canonical text of an index value, as C# IndexValue and RenderCanonicalNumber, or None when it names no + value: a string is as is (never trimmed), a boolean is `True`/`False`, an integer is its digits, a non-integral + finite float is its exact decimal expansion, NaN and infinities and blank strings are no value.""" + if value is None: + return None + if isinstance(value, bool): # before int: bool is an int subclass + return "True" if value else "False" + if isinstance(value, int): + return str(value) + if isinstance(value, float): + if not math.isfinite(value): + return None + if value.is_integer(): + return str(int(value)) # -0.0 is 0 + text = format(Decimal(value), "f") # Decimal(float) is exact + return text.rstrip("0").rstrip(".") if "." in text else text + text = value if isinstance(value, str) else str(value) + return None if all(char in _BLANK_INDEX_CHARS for char in text) else text + + @dataclass class _State: documents: dict[str, DocumentDefinition[Any]] = field(default_factory=dict) @@ -432,12 +480,20 @@ class CultCache: """Validates every record (home and name and index extractors included) before the store is touched; once the store accepts, applying to the cache has no fallible step.""" type = document.type + # A batch naming a key twice lands the last record for it, as C# CultCacheBatch stages one operation per key. The + # judge, the store and the apply all read this one list. + records = list({envelope.key: (envelope, value) for envelope, value in records}.values()) name_extractor = self._state.name_extractors.get(type) index_extractors = self._state.index_extractors.get(type, {}) admitted = [ (envelope, value, self._lookup_keys_for(value, name_extractor, index_extractors)) for envelope, value in records ] + self._refuse_index_sharing( + [(type, envelope.key, keys) for envelope, _, keys in admitted], + self._state.indexes, + {(type, envelope.key) for envelope, _ in records}, + ) store = self._store_for_type(type) legacy_key = self._state.legacy_global_keys.get(type) if store is not None: @@ -552,9 +608,9 @@ class CultCache: name = None if name_extractor is None else extract_value(value, name_extractor) indexes: dict[str, str] = {} for index, extractor in index_extractors.items(): - index_value = extract_value(value, extractor) + index_value = index_text(extract_value(value, extractor)) if index_value is not None: - indexes[index] = str(index_value) + indexes[index] = index_value return _LookupKeys(None if name is None else str(name), indexes) def _derive_lookups( @@ -571,17 +627,52 @@ class CultCache: name_extractor = name_extractors.get(type) type_index_extractors = index_extractors.get(type, {}) for key, value in values.items(): - keys = self._lookup_keys_for(value, name_extractor, type_index_extractors) - lookup_keys.setdefault(type, {})[key] = keys + lookup_keys.setdefault(type, {})[key] = self._lookup_keys_for(value, name_extractor, type_index_extractors) + # Judged whole, over every type at once and on the file alone (never against what the cache held): a held value can + # have been mutated in place since it was admitted, and another process may have moved a value between records. + self._refuse_index_sharing( + [(type, key, keys) for type, by_key in lookup_keys.items() for key, keys in by_key.items()], {}, () + ) + for type in lookup_keys: + for key, keys in lookup_keys.get(type, {}).items(): if keys.name is not None: names[(type, keys.name)] = key for index, index_value in keys.indexes.items(): indexes[(type, index, index_value)] = key return names, indexes, lookup_keys + def _refuse_index_sharing( + self, + landing: list[tuple[str, str, _LookupKeys]], + held: dict[tuple[str, str, str], str], + replaced: Iterable[tuple[str, str]], + ) -> None: + """The one index rule (variants:ruling:unique-indexes), in the order of C# RefuseIndexSharing: within one type and one + unique alias, no two records hold one non-blank value, judged on the result. The holders of a value are the landing + keys projecting it (in landing order) plus the held key unless this admission replaces it; two distinct holders + refuse, naming the two smallest keys (UTF-16 code-unit order) and the alias, never the value. Aliases are judged in + UTF-16 order, values in the order they landed.""" + released = set(replaced) + landed: dict[tuple[str, str, str], list[str]] = {} + for type, key, keys in landing: + for index, index_value in keys.indexes.items(): + landed.setdefault((type, index, index_value), []).append(key) + for (type, index, index_value), keys in sorted(landed.items(), key=lambda item: _utf16_order(item[0][1])): + held_key = held.get((type, index, index_value)) + if held_key is not None and (type, held_key) not in released: + keys = [*keys, held_key] + distinct = sorted(dict.fromkeys(keys), key=_utf16_order) + if len(distinct) > 1: + document = self._state.documents.get(type) + schema_name = (document.schema_name if document is not None else None) or type + raise CultCacheError( + f"Records {distinct[0]} and {distinct[1]} ({schema_name}) both hold unique index '{index}'; " + "an index value names one record." + ) + def _remove_lookups(self, type: str, key: str) -> None: - """Removes the lookups the held record produced at admission, handing a name or index value to - another held record that produced it too. Reads stored lookup keys only; runs no extractor.""" + """Removes the lookups the held record produced at admission, handing a name to another held + record that produced it too (an index value has one holder). Reads stored lookup keys only; runs no extractor.""" held = self._state.lookup_keys.get(type, {}) keys = held.get(key) if keys is None: @@ -596,9 +687,6 @@ class CultCache: if self._state.indexes.get(lookup_key) != key: continue del self._state.indexes[lookup_key] - for candidate_key, candidate in held.items(): - if candidate_key != key and candidate.indexes.get(index) == index_value: - self._state.indexes[lookup_key] = candidate_key def _assert_registered(self, document: DocumentDefinition[Any]) -> None: if self._state.documents.get(document.type) is not document: diff --git a/packages/cultcache-py/src/cultcache_py/documents.py b/packages/cultcache-py/src/cultcache_py/documents.py index 23849713..6101c136 100644 --- a/packages/cultcache-py/src/cultcache_py/documents.py +++ b/packages/cultcache-py/src/cultcache_py/documents.py @@ -157,6 +157,11 @@ class DocumentDefinition(Generic[T]): global_document: bool = False name: str | Extractor[T] | None = None indexes: dict[str, str | Extractor[T]] = field(default_factory=dict) + """Unique lookups (C# `[CultIndex]`): no two records of the type hold one non-blank value, judged at every commit and + load. `get_by_index` answers the one holder.""" + roles: dict[str, str | Extractor[T]] = field(default_factory=dict) + """Selection aliases (C# `[CultRole]`): many records may share a value. Never indexed for lookup, never refuse; the + member's catalog `index_alias` is the same either way.""" payload_encoder: Callable[[Any], bytes] = _json_payload_encoder payload_decoder: Callable[[bytes], Any] = _json_payload_decoder schema_id: str | None = None @@ -190,6 +195,14 @@ class DocumentDefinition(Generic[T]): self.validate(value) return value + def __post_init__(self) -> None: + # Judged here so every constructor passes it: the factories, the dataclass constructor and dataclasses.replace. + for alias in self.roles: + if alias in self.indexes: + raise ValueError( + f"Document type {self.type} declares '{alias}' as both an index and a role; an index already names its role." + ) + def catalog_entry(self) -> CultCacheSchemaCatalogEntry: if self._catalog_entry is not None: return self._catalog_entry @@ -241,6 +254,7 @@ def define_document_type( global_document: bool = False, name: str | Extractor[T] | None = None, indexes: dict[str, str | Extractor[T]] | None = None, + roles: dict[str, str | Extractor[T]] | None = None, payload_encoder: Callable[[Any], bytes] = _json_payload_encoder, payload_decoder: Callable[[bytes], Any] = _json_payload_decoder, schema_id: str | None = None, @@ -259,6 +273,7 @@ def define_document_type( global_document=global_document, name=name, indexes=indexes or {}, + roles=roles or {}, payload_encoder=payload_encoder, payload_decoder=payload_decoder, schema_id=schema_id, @@ -280,6 +295,7 @@ def define_database_entry_type( global_document: bool = False, name: str | Extractor[T] | None = None, indexes: dict[str, str | Extractor[T]] | None = None, + roles: dict[str, str | Extractor[T]] | None = None, schema_id: str | None = None, schema_name: str | None = None, schema_version: str | None = None, @@ -296,7 +312,7 @@ def define_database_entry_type( normalized = _normalize_fields(type, fields) member_name_lookup = name if isinstance(name, str) else None - index_lookup = indexes or {} + index_lookup = {**(roles or {}), **(indexes or {})} def members() -> tuple[CultCacheSchemaCatalogMember, ...]: return tuple( @@ -323,6 +339,7 @@ def define_database_entry_type( global_document=global_document, name=name, indexes=indexes, + roles=roles, payload_encoder=_pack_tree, payload_decoder=_messagepack_payload_decoder, schema_id=schema_id, diff --git a/packages/cultcache-py/tests/test_cultcache.py b/packages/cultcache-py/tests/test_cultcache.py index 1dd41a0e..92892b51 100644 --- a/packages/cultcache-py/tests/test_cultcache.py +++ b/packages/cultcache-py/tests/test_cultcache.py @@ -103,20 +103,20 @@ class CultCacheTests(unittest.TestCase): self.assertEqual(cache.get_key_by_name(document, "Elixir"), "item:potion") self.assertEqual(cache.get_by_index(document, "category", "Rare").name, "Elixir") - def test_incremental_lookup_delete_restores_duplicate_owner(self) -> None: + def test_incremental_lookup_delete_restores_duplicate_name_owner(self) -> None: document = item_doc() cache = CultCache() cache.register_document_type(document) cache.put(document, "item:first", Item(name="Potion", category="Consumable", value=50)) - cache.put(document, "item:second", Item(name="Potion", category="Consumable", value=60)) + cache.put(document, "item:second", Item(name="Potion", category="Rare", value=60)) self.assertEqual(cache.get_key_by_name(document, "Potion"), "item:second") - self.assertEqual(cache.get_key_by_index(document, "category", "Consumable"), "item:second") cache.delete(document, "item:second") self.assertEqual(cache.get_key_by_name(document, "Potion"), "item:first") self.assertEqual(cache.get_key_by_index(document, "category", "Consumable"), "item:first") + self.assertIsNone(cache.get_key_by_index(document, "category", "Rare")) def test_database_entry_formatter_uses_slot_indexed_messagepack_array(self) -> None: try: diff --git a/packages/cultcache-py/tests/test_first_call.py b/packages/cultcache-py/tests/test_first_call.py index f8d4b52b..10d6eb27 100644 --- a/packages/cultcache-py/tests/test_first_call.py +++ b/packages/cultcache-py/tests/test_first_call.py @@ -24,7 +24,6 @@ item_doc = define_document_type( encode=lambda item: asdict(item), decode=lambda raw: Item(**raw), name="name", - indexes={"category": "category"}, ) diff --git a/packages/cultcache-py/tests/test_unique_index.py b/packages/cultcache-py/tests/test_unique_index.py new file mode 100644 index 00000000..0dca2aa8 --- /dev/null +++ b/packages/cultcache-py/tests/test_unique_index.py @@ -0,0 +1,575 @@ +from __future__ import annotations + +import itertools +import shutil +import tempfile +import unittest +from dataclasses import asdict, dataclass +from decimal import Decimal +from pathlib import Path + +import msgpack + +from cultcache_py import ( + CultCache, + CultCacheEnvelope, + SingleFileMessagePackBackingStore, + define_database_entry_type, + define_document_type, + leaves as t, + database_entry_field, +) +from cultcache_py.cache import CultCacheError, index_text + +# variants:ruling:unique-indexes. `indexes` are unique within one type, `roles` are selection aliases. The vectors in +# tests/vectors/unique-index are written by the C# reference; the rest of the behaviour is pinned here. +VECTORS = Path(__file__).resolve().parents[3] / "tests" / "vectors" / "unique-index" + + +@dataclass +class Item: + sku: object = None + serial: object = None + group: object = None + + +def refusal(key_a: str, key_b: str, schema_name: str, alias: str) -> str: + return f"Records {key_a} and {key_b} ({schema_name}) both hold unique index '{alias}'; an index value names one record." + + +def item_doc(type: str = "item", *, indexes=None, roles=None): + return define_document_type( + type, + encode=asdict, + decode=lambda raw: Item(**raw), + indexes={"sku": "sku", "serial": "serial"} if indexes is None else indexes, + roles={"group": "group"} if roles is None else roles, + ) + + +def envelope(document, key: str, item: Item) -> CultCacheEnvelope: + return CultCacheEnvelope.create(key=key, type=document.type, payload=document.encode_payload(item)) + + +def cache_of(*documents, store=None) -> CultCache: + builder = CultCache.builder() + for document in documents: + builder.register_document_type(document) + if store is not None: + builder.add_generic_store(store) + return builder.build() + + +class RecordingStore: + """A store that counts what reaches it; the judge must refuse before any store call.""" + + def __init__(self) -> None: + self.pushed: list[str] = [] + + def pull_all(self): + return [] + + def push(self, envelope) -> None: + self.pushed.append(envelope.key) + + def push_all(self, envelopes) -> None: + self.pushed.extend(envelope.key for envelope in envelopes) + + def delete(self, type, key) -> None: + pass + + +class IndexTextTests(unittest.TestCase): + def test_canonical_text(self) -> None: + cases = [ + ("S-1", "S-1"), + (" a ", " a "), + ("", ""), + ("\x1f", "\x1f"), + ("\x1c", "\x1c"), + ("\x1d", "\x1d"), + ("\x1e", "\x1e"), + ("á Ž", "á Ž"), + ("​", "​"), + (True, "True"), + (False, "False"), + (7, "7"), + (-7, "-7"), + (9223372036854775807, "9223372036854775807"), + (18446744073709551615, "18446744073709551615"), + (0.0, "0"), + (-0.0, "0"), + (2.0, "2"), + (-3.0, "-3"), + (1e21, "1000000000000000000000"), + (0.1, "0.1000000000000000055511151231257827021181583404541015625"), + (-0.5, "-0.5"), + (2.5e-07, "0.00000024999999999999998868702795647156467140348468092270195484161376953125"), + ] + for value, text in cases: + with self.subTest(value=value): + self.assertEqual(index_text(value), text) + self.assertIsInstance(index_text(value), str) + + def test_no_value(self) -> None: + blank = ["", " ", "\t", "\n", "\x0b", "\x0c", "\r", "\x85", "\xa0", " ", "
", "
", " ", + "â�Ÿ", " ", "  \x85 ", *map(chr, range(0x2000, 0x200B))] + for value in [None, float("nan"), float("inf"), float("-inf"), *blank]: + with self.subTest(value=repr(value)): + self.assertIsNone(index_text(value)) + + def test_blank_set_is_exactly_the_csharp_set(self) -> None: + csharp = {*range(0x09, 0x0E), 0x20, 0x85, 0xA0, 0x1680, *range(0x2000, 0x200B), 0x2028, 0x2029, 0x202F, 0x205F, 0x3000} + self.assertEqual(len(csharp), 25) + got = {code for code in range(0x110000) if 0xD800 > code or code > 0xDFFF if index_text(chr(code)) is None} + self.assertEqual(got, csharp) + # Python's isspace is the wrong test: it also counts U+001C-001F. + self.assertTrue("\x1f".isspace()) + self.assertEqual(index_text("\x1f"), "\x1f") + + def test_floats_render_exactly_not_through_str(self) -> None: + for value in (0.1, 1e-7, 123456789.123456789, 5e-324, 1.7976931348623157e308): + with self.subTest(value=value): + text = index_text(value) + self.assertNotIn("e", text.lower()) + self.assertEqual(Decimal(text), Decimal(value)) + self.assertFalse(text.endswith("0") and "." in text) + + def test_bool_is_not_an_integer(self) -> None: + self.assertEqual(index_text(True), "True") + self.assertNotEqual(index_text(True), "1") + + +class UniqueIndexTests(unittest.TestCase): + def test_a_write_of_a_held_value_at_another_key_is_refused_by_every_write_path(self) -> None: + for alias in ("sku", "serial"): # two unique indexes: each is judged + other = "serial" if alias == "sku" else "sku" + paths = { + "put": lambda cache, d, k, i: cache.put(d, k, i), + "put_envelope": lambda cache, d, k, i: cache.put_envelope(d, envelope(d, k, i)), + "put_envelopes": lambda cache, d, k, i: cache.put_envelopes(d, [envelope(d, k, i)]), + "update": lambda cache, d, k, i: cache.update(d, k, lambda _held: i), + } + for name, write in paths.items(): + with self.subTest(alias=alias, path=name): + document = item_doc() + store = RecordingStore() + cache = cache_of(document, store=store) + cache.put(document, "item:b", Item(sku="CANARY-b", serial="canary-b")) + cache.put(document, "item:a", Item(sku="CANARY-a", serial="canary-a")) + before = cache.snapshot() + pushed = list(store.pushed) + taken = "CANARY-b" if alias == "sku" else "canary-b" + landing = Item(**{alias: taken, other: "fresh"}) + with self.assertRaises(CultCacheError) as caught: + write(cache, document, "item:a", landing) + self.assertEqual(str(caught.exception), refusal("item:a", "item:b", "item", alias)) + self.assertNotIn("anary", str(caught.exception)) + self.assertEqual(cache.snapshot(), before) + self.assertEqual(store.pushed, pushed) + self.assertEqual(cache.get_key_by_index(document, alias, taken), "item:b") + self.assertIsNone(cache.get_key_by_index(document, other, "fresh")) + + def test_a_record_rewriting_its_own_key_keeps_its_value(self) -> None: + document = item_doc() + cache = cache_of(document) + cache.put(document, "item:a", Item(sku="S", serial="1", group="g")) + cache.put(document, "item:a", Item(sku="S", serial="2", group="h")) + self.assertEqual(cache.get_key_by_index(document, "sku", "S"), "item:a") + self.assertIsNone(cache.get_key_by_index(document, "serial", "1")) + self.assertEqual(cache.get_key_by_index(document, "serial", "2"), "item:a") + + def test_the_refusal_names_the_schema_not_the_type(self) -> None: + document = define_document_type( + "t.item", indexes={"sku": "sku"}, schema_name="schema.Item", schema_id="schema.Item.v1" + ) + cache = cache_of(document) + cache.put(document, "k:1", {"sku": "S"}) + with self.assertRaises(CultCacheError) as caught: + cache.put(document, "k:2", {"sku": "S"}) + self.assertEqual(str(caught.exception), refusal("k:1", "k:2", "schema.Item", "sku")) + + def test_the_two_smallest_keys_are_named_in_ordinal_order(self) -> None: + document = item_doc() + cache = cache_of(document) + cache.put(document, "item:m", Item(sku="S")) + with self.assertRaises(CultCacheError) as caught: + cache.put_envelopes(document, [envelope(document, "item:z", Item(sku="S")), envelope(document, "item:A", Item(sku="S"))]) + self.assertEqual(str(caught.exception), refusal("item:A", "item:m", "item", "sku")) + + def test_put_envelopes_holding_one_value_twice_is_refused_whole(self) -> None: + document = item_doc() + store = RecordingStore() + cache = cache_of(document, store=store) + with self.assertRaises(CultCacheError) as caught: + cache.put_envelopes( + document, + [ + envelope(document, "item:b", Item(sku="S")), + envelope(document, "item:c", Item(sku="T")), + envelope(document, "item:a", Item(sku="S")), + ], + ) + self.assertEqual(str(caught.exception), refusal("item:a", "item:b", "item", "sku")) + self.assertEqual(cache.snapshot(), {}) + self.assertEqual(store.pushed, []) + self.assertIsNone(cache.get_key_by_index(document, "sku", "T")) + + def test_verdict_does_not_depend_on_batch_order(self) -> None: + document = item_doc() + rows = [("item:a", Item(sku="S", serial="1")), ("item:b", Item(sku="T", serial="1")), ("item:c", Item(sku="U"))] + for order in itertools.permutations(rows): + with self.subTest(order=[key for key, _ in order]): + cache = cache_of(document) + with self.assertRaises(CultCacheError) as caught: + cache.put_envelopes(document, [envelope(document, key, item) for key, item in order]) + self.assertEqual(str(caught.exception), refusal("item:a", "item:b", "item", "serial")) + self.assertEqual(cache.snapshot(), {}) + + def test_put_envelopes_swapping_two_records_values_lands(self) -> None: + document = item_doc() + cache = cache_of(document) + cache.put(document, "item:a", Item(sku="S", serial="1")) + cache.put(document, "item:b", Item(sku="T", serial="2")) + cache.put_envelopes( + document, + [envelope(document, "item:a", Item(sku="T", serial="2")), envelope(document, "item:b", Item(sku="S", serial="1"))], + ) + self.assertEqual(cache.get_key_by_index(document, "sku", "S"), "item:b") + self.assertEqual(cache.get_key_by_index(document, "sku", "T"), "item:a") + self.assertEqual(cache.get_key_by_index(document, "serial", "1"), "item:b") + self.assertEqual(cache.get_key_by_index(document, "serial", "2"), "item:a") + + def test_a_batch_may_take_a_value_the_batch_releases(self) -> None: + document = item_doc() + cache = cache_of(document) + cache.put(document, "item:a", Item(sku="S")) + cache.put_envelopes(document, [envelope(document, "item:a", Item(sku="T")), envelope(document, "item:b", Item(sku="S"))]) + self.assertEqual(cache.get_key_by_index(document, "sku", "S"), "item:b") + self.assertEqual(cache.get_key_by_index(document, "sku", "T"), "item:a") + + def test_delete_frees_the_value(self) -> None: + document = item_doc() + cache = cache_of(document) + cache.put(document, "item:a", Item(sku="S")) + cache.delete(document, "item:a") + self.assertIsNone(cache.get_key_by_index(document, "sku", "S")) + cache.put(document, "item:b", Item(sku="S")) + self.assertEqual(cache.get_key_by_index(document, "sku", "S"), "item:b") + + def test_blank_values_never_conflict(self) -> None: + document = item_doc() + cache = cache_of(document) + blanks = ["", " ", "\x85", " ", None] + for number, blank in enumerate(blanks): + cache.put(document, f"item:blank{number}", Item(sku=blank, serial=blank)) + cache.put_envelopes(document, [envelope(document, "item:more", Item(sku=" ")), envelope(document, "item:more2", Item(sku=""))]) + for blank in ("", " ", "\x85"): + self.assertIsNone(cache.get_key_by_index(document, "sku", blank)) + # U+001F and U+FEFF are values. + cache.put(document, "item:us", Item(sku="\x1f")) + cache.put(document, "item:bom", Item(sku="")) + self.assertEqual(cache.get_key_by_index(document, "sku", "\x1f"), "item:us") + self.assertEqual(cache.get_key_by_index(document, "sku", ""), "item:bom") + with self.assertRaises(CultCacheError): + cache.put(document, "item:us2", Item(sku="\x1f")) + + def test_values_compare_as_the_canonical_text(self) -> None: + document = item_doc() + cache = cache_of(document) + cache.put(document, "item:a", Item(sku=1)) + for same in (1, 1.0, "1"): + with self.subTest(same=same), self.assertRaises(CultCacheError): + cache.put(document, "item:b", Item(sku=same)) + cache.put(document, "item:true", Item(sku=True)) + self.assertEqual(cache.get_key_by_index(document, "sku", "True"), "item:true") + self.assertEqual(cache.get_key_by_index(document, "sku", "1"), "item:a") + cache.put(document, "item:f", Item(sku=0.1)) + self.assertEqual(cache.get_key_by_index(document, "sku", "0.1000000000000000055511151231257827021181583404541015625"), "item:f") + + def test_two_types_may_share_an_alias_and_a_value(self) -> None: + one, two = item_doc("one"), item_doc("two") + cache = cache_of(one, two) + cache.put(one, "k:1", Item(sku="S", serial="1")) + cache.put(two, "k:2", Item(sku="S", serial="1")) + self.assertEqual(cache.get_key_by_index(one, "sku", "S"), "k:1") + self.assertEqual(cache.get_key_by_index(two, "sku", "S"), "k:2") + cache.delete(one, "k:1") + self.assertEqual(cache.get_key_by_index(two, "sku", "S"), "k:2") + + def test_roles_may_share_values_and_are_not_looked_up(self) -> None: + document = item_doc() + cache = cache_of(document) + cache.put(document, "item:a", Item(sku="1", group="g")) + cache.put(document, "item:b", Item(sku="2", group="g")) + self.assertIsNone(cache.get_key_by_index(document, "group", "g")) + self.assertIsNone(cache.get_by_index(document, "group", "g")) + + def test_an_alias_in_both_indexes_and_roles_is_refused(self) -> None: + with self.assertRaisesRegex(ValueError, "both an index and a role"): + define_document_type("bad", indexes={"a": "a"}, roles={"a": "a"}) + with self.assertRaisesRegex(ValueError, "both an index and a role"): + define_database_entry_type("bad", [("a", 0)], indexes={"a": "a"}, roles={"a": "a"}) + define_document_type("fine", indexes={"a": "a"}, roles={"b": "a"}) + + def test_register_index_over_held_duplicates_installs_nothing(self) -> None: + document = item_doc(indexes={}, roles={}) + cache = cache_of(document) + cache.put(document, "item:b", Item(sku="S", serial="1")) + cache.put(document, "item:a", Item(sku="S", serial="2")) + with self.assertRaises(CultCacheError) as caught: + cache.register_index(document, "sku", "sku") + self.assertEqual(str(caught.exception), refusal("item:a", "item:b", "item", "sku")) + self.assertIsNone(cache.get_key_by_index(document, "sku", "S")) + cache.register_index(document, "serial", "serial") # an installed alias is not poisoned by the refused one + self.assertEqual(cache.get_key_by_index(document, "serial", "2"), "item:a") + # Nothing was installed, so a write sharing the refused alias's value is still admitted. + cache.put(document, "item:c", Item(sku="S", serial="3")) + + def test_registration_names_an_installed_alias_a_caller_mutated_into_a_collision(self) -> None: + document = item_doc(indexes={"sku": "sku"}, roles={}) + cache = cache_of(document) + cache.put(document, "item:x", Item(sku="1", serial="s1")) + cache.put(document, "item:y", Item(sku="2", serial="s2")) + cache.get_required(document, "item:y").sku = "1" # the cache hands out its held value + with self.assertRaises(CultCacheError) as caught: + cache.register_index(document, "serial", "serial") + self.assertEqual(str(caught.exception), refusal("item:x", "item:y", "item", "sku")) + + def test_derive_names_the_ordinal_first_alias_then_value(self) -> None: + document = item_doc(indexes={}, roles={}) + cache = cache_of(document) + cache.put(document, "item:1", Item(sku="a", serial="p", group="x")) + cache.put(document, "item:2", Item(sku="a", serial="p", group="x")) + cache.put(document, "item:3", Item(sku="b", serial="q", group="y")) + cache.put(document, "item:4", Item(sku="b", serial="q", group="y")) + with self.assertRaises(CultCacheError) as caught: + cache.register_index(document, "serial", "serial") + self.assertEqual(str(caught.exception), refusal("item:1", "item:2", "item", "serial")) + + def test_a_document_in_two_unique_indexes_loads_and_registers_through_every_path(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "items.cc" + document = item_doc() + cache = cache_of(document, store=SingleFileMessagePackBackingStore(path)) + cache.put(document, "item:a", Item(sku="S", serial="1")) + cache.put(document, "item:b", Item(sku="T", serial="2")) + # a store written by a looser definition, then opened by this one + loose = item_doc(indexes={}, roles={}) + writer = cache_of(loose, store=SingleFileMessagePackBackingStore(path)) + writer.pull_all_backing_stores() + writer.put(loose, "item:c", Item(sku="U", serial="2")) + before = path.read_bytes() + reader = cache_of(document, store=SingleFileMessagePackBackingStore(path)) + with self.assertRaises(CultCacheError) as caught: + reader.pull_all_backing_stores() + self.assertEqual(str(caught.exception), refusal("item:b", "item:c", "item", "serial")) + self.assertEqual(path.read_bytes(), before) + self.assertEqual(reader.snapshot(), {}) + + def test_a_refused_load_keeps_the_held_state(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "items.cc" + loose = item_doc(indexes={}, roles={}) + writer = cache_of(loose, store=SingleFileMessagePackBackingStore(path)) + writer.put(loose, "item:a", Item(sku="S")) + writer.put(loose, "item:b", Item(sku="S")) + document = item_doc() + cache = cache_of(document, store=SingleFileMessagePackBackingStore(path)) + with self.assertRaises(CultCacheError): + cache.pull_all_backing_stores() + self.assertIsNone(cache.get_key_by_index(document, "sku", "S")) + self.assertEqual(cache.get_all(document), []) + + +class SimulationFactTests(unittest.TestCase): + def test_simulation_fact_catalog_entry_is_unchanged_by_indexes_to_roles(self) -> None: + import re + + from cultnet_py.cultmesh_contracts import SIMULATION_FACT_DOCUMENT_TYPE, simulation_fact_document as document + + aliases = {"shard": "shard_id", "frame": "frame", "subject": "subject_id", "claim_kind": "claim_kind"} + self.assertEqual(document.roles, aliases) + self.assertEqual(document.indexes, {}) + entry = document.catalog_entry() + snake = lambda name: re.sub(r"(? None: + from cultnet_py.cultmesh_contracts import CultMeshSimulationFact, simulation_fact_document as document + + cache = cache_of(document) + for number in range(3): + fact = CultMeshSimulationFact.from_candidate( + {"shardId": "s", "shardEpoch": 1, "frame": 4, "subjectId": "x", "claimKind": "k", "claimHash": f"h{number}", + "witnessCount": 1, "supportWeight": 1.0, "totalWeight": 1.0, "confidence": 1.0} + ) + cache.put(document, f"simulation:{number}", fact) + self.assertEqual(len(cache.get_all(document)), 3) + self.assertIsNone(cache.get_key_by_index(document, "shard", "s")) + + +class VectorTests(unittest.TestCase): + def test_values_rows(self) -> None: + document = item_doc(indexes={"sku": "sku"}, roles={}) + for label, kind, value, text in msgpack.unpackb((VECTORS / "values.msgpack").read_bytes(), raw=False): + with self.subTest(label=label): + self.assertEqual(index_text(value), text) + cache = cache_of(document) + cache.put(document, "item:1", Item(sku=value)) + if text is None: + cache.put(document, "item:2", Item(sku=value)) # blank: two records commit + self.assertEqual(len(cache.get_all(document)), 2) + else: + self.assertEqual(cache.get_key_by_index(document, "sku", text), "item:1") + with self.assertRaises(CultCacheError): + cache.put(document, "item:2", Item(sku=value)) + + def test_store_verdicts(self) -> None: + unit = define_database_entry_type + outcomes = msgpack.unpackb((VECTORS / "outcomes.msgpack").read_bytes(), raw=False) + self.assertEqual(len(outcomes), 3) + for name, alias, verdict, key_a, key_b in outcomes: + for roles_first in (False, True): # the verdict does not depend on declaration order + with self.subTest(file=name, alias=alias, roles_first=roles_first), tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / name + shutil.copyfile(VECTORS / name, path) + before = path.read_bytes() + fields = [database_entry_field("sku", 0, codec=t.string), database_entry_field("category", 1, codec=t.string)] + declared = {"indexes": {alias: "sku"} if alias else {}, "roles": {} if alias else {"Sku": "sku"}} + if roles_first: + declared = dict(reversed(list(declared.items()))) + document = unit( + "tests.unique_item", + fields, + schema_id="sha256:93bb890de83069061af696b23f94fd32fb3c2dad3c91ee645e8add56085f1882", + schema_name="tests.unique_item", + **declared, + ) + cache = cache_of(document, store=SingleFileMessagePackBackingStore(path)) + if verdict == "open": + cache.pull_all_backing_stores() + self.assertGreaterEqual(len(cache.get_all(document)), 2) + else: + with self.assertRaises(CultCacheError) as caught: + cache.pull_all_backing_stores() + self.assertEqual(str(caught.exception), refusal(key_a, key_b, "tests.unique_item", alias)) + self.assertNotIn("S-1", str(caught.exception)) + self.assertEqual(cache.snapshot(), {}) + self.assertEqual(path.read_bytes(), before) + + +class ListStore(RecordingStore): + """A store whose contents the test swaps between loads: another process rewrote the file.""" + + def __init__(self, envelopes=()) -> None: + super().__init__() + self.envelopes = list(envelopes) + + def pull_all(self): + return list(self.envelopes) + + +class BatchAndOrderTests(unittest.TestCase): + def test_a_batch_naming_a_key_twice_lands_the_last_record_and_keeps_every_held_lookup(self) -> None: + # C# CultCacheBatch stages one operation per key, the last one named. + document = item_doc() + store = RecordingStore() + cache = cache_of(document, store=store) + cache.put(document, "item:h", Item(sku="S")) + store.pushed.clear() + cache.put_envelopes( + document, [envelope(document, "item:k", Item(sku="S")), envelope(document, "item:k", Item(sku="T"))] + ) + self.assertEqual(store.pushed, ["item:k"]) + self.assertEqual(cache.get_required(document, "item:k").sku, "T") + self.assertEqual(cache.get_key_by_index(document, "sku", "S"), "item:h") + self.assertEqual(cache.get_key_by_index(document, "sku", "T"), "item:k") + with self.assertRaises(CultCacheError) as caught: + cache.put(document, "item:z", Item(sku="S")) + self.assertEqual(str(caught.exception), refusal("item:h", "item:z", "item", "sku")) + + def test_a_batch_naming_a_key_twice_is_judged_on_the_record_that_lands(self) -> None: + document = item_doc() + cache = cache_of(document) + cache.put(document, "item:h", Item(sku="S")) + # the first record for k would collide with h; only the last lands, so the batch is admitted + cache.put_envelopes( + document, [envelope(document, "item:k", Item(sku="S")), envelope(document, "item:k", Item(sku="T"))] + ) + # and the reverse order lands the colliding record, which is refused + with self.assertRaises(CultCacheError): + cache.put_envelopes( + document, [envelope(document, "item:j", Item(sku="U")), envelope(document, "item:j", Item(sku="S"))] + ) + self.assertNotIn("item:j", cache.snapshot().get("item", {})) + + def test_values_are_named_in_landing_order(self) -> None: + document = item_doc() + cache = cache_of(document) + rows = [("item:a", "B"), ("item:b", "B"), ("item:x", "A"), ("item:y", "A")] + with self.assertRaises(CultCacheError) as caught: + cache.put_envelopes(document, [envelope(document, key, Item(sku=sku)) for key, sku in rows]) + self.assertEqual(str(caught.exception), refusal("item:a", "item:b", "item", "sku")) + + def test_keys_sort_by_utf16_code_unit_not_code_point(self) -> None: + document = item_doc() + cache = cache_of(document) + astral, bmp = "k\U0001F600", "k\uff01" # code points: bmp < astral; UTF-16 units: D83D < FF01 + with self.assertRaises(CultCacheError) as caught: + cache.put_envelopes(document, [envelope(document, bmp, Item(sku="S")), envelope(document, astral, Item(sku="S"))]) + self.assertEqual(str(caught.exception), refusal(astral, bmp, "item", "sku")) + + def test_a_load_judges_every_type_at_once_aliases_in_ordinal_order(self) -> None: + first = item_doc("first", indexes={"zeta": "sku"}, roles={}) + second = item_doc("second", indexes={"alpha": "sku"}, roles={}) + loose_first, loose_second = item_doc("first", indexes={}, roles={}), item_doc("second", indexes={}, roles={}) + store = ListStore( + [envelope(loose_first, "f:1", Item(sku="S")), envelope(loose_first, "f:2", Item(sku="S"))] + + [envelope(loose_second, "s:1", Item(sku="S")), envelope(loose_second, "s:2", Item(sku="S"))] + ) + cache = cache_of(first, second, store=store) + with self.assertRaises(CultCacheError) as caught: + cache.pull_all_backing_stores() + self.assertEqual(str(caught.exception), refusal("s:1", "s:2", "second", "alpha")) + + +class LoadFreshnessTests(unittest.TestCase): + def test_a_load_is_judged_on_the_file_alone_not_against_the_held_map(self) -> None: + document = item_doc() + store = ListStore([envelope(document, "item:b", Item(sku="S")), envelope(document, "item:c", Item(sku="T"))]) + cache = cache_of(document, store=store) + cache.pull_all_backing_stores() + self.assertEqual(cache.get_key_by_index(document, "sku", "S"), "item:b") + # another process moved S from b to a, and b to T's old place + store.envelopes = [envelope(document, "item:a", Item(sku="S")), envelope(document, "item:b", Item(sku="U")), store.envelopes[1]] + cache.pull_all_backing_stores() + self.assertEqual(cache.get_key_by_index(document, "sku", "S"), "item:a") + self.assertEqual(cache.get_key_by_index(document, "sku", "U"), "item:b") + + +class DefinitionTests(unittest.TestCase): + def test_every_constructor_refuses_an_alias_in_both_indexes_and_roles(self) -> None: + from dataclasses import replace + + from cultcache_py import DocumentDefinition + + with self.assertRaisesRegex(ValueError, "both an index and a role"): + DocumentDefinition(type="item", indexes={"sku": "sku"}, roles={"sku": "sku"}) + with self.assertRaisesRegex(ValueError, "both an index and a role"): + replace(item_doc(roles={}), roles={"sku": "sku"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/cultmesh-py/README.md b/packages/cultmesh-py/README.md index 60ae4e49..020a2afc 100644 --- a/packages/cultmesh-py/README.md +++ b/packages/cultmesh-py/README.md @@ -43,9 +43,10 @@ from cultmesh_py import ( note_doc = define_database_entry_type( "mesh.note", - [("body", 0), ("name", 1), ("kind", 2)], + [("body", 0), ("name", 1), ("kind", 2), ("slug", 3)], name="name", - indexes={"kind": "kind"}, + indexes={"slug": "slug"}, # unique: no two notes hold one slug; watch_by_index and get_key_by_index look it up + roles={"kind": "kind"}, # many notes share a kind: a selection alias, never a lookup ) node = CultMesh.start_node( "mesh.cc", @@ -55,13 +56,13 @@ node = CultMesh.start_node( node.database.register_document(note_doc) unsubscribe = node.database.watch_record(note_doc, "note:1", lambda change: print(change.change_kind)) unsubscribe_named = node.database.watch_by_name(note_doc, "intro", lambda change: print(change.record_key)) -unsubscribe_kind = node.database.watch_by_index(note_doc, "kind", "guide", lambda change: print(change.record_key)) -node.database.put(note_doc, "note:1", {"body": "hello", "name": "intro", "kind": "guide"}) +unsubscribe_slug = node.database.watch_by_index(note_doc, "slug", "intro-guide", lambda change: print(change.record_key)) +node.database.put(note_doc, "note:1", {"body": "hello", "name": "intro", "kind": "guide", "slug": "intro-guide"}) live_note = node.database.get_required(note_doc, "note:1") put_message = node.database.put_raw_message( note_doc, "note:2", - {"body": "wire me", "name": "wire", "kind": "interop"}, + {"body": "wire me", "name": "wire", "kind": "interop", "slug": "wire-me"}, shard_id="interop", shard_epoch=1, ) diff --git a/packages/cultmesh-py/src/cultmesh_py/node.py b/packages/cultmesh-py/src/cultmesh_py/node.py index cee102f0..619aed36 100644 --- a/packages/cultmesh-py/src/cultmesh_py/node.py +++ b/packages/cultmesh-py/src/cultmesh_py/node.py @@ -10,7 +10,7 @@ from pathlib import Path from typing import Any, Callable from cultcache_py import CultCache, CultCacheEnvelope, SingleFileMessagePackBackingStore -from cultcache_py.cache import CultCacheError +from cultcache_py.cache import CultCacheError, index_text from cultcache_py.documents import DocumentDefinition, extract_value from cultnet_py import ( CultNetAppliedRecord, @@ -520,7 +520,7 @@ class CultMeshDatabase: raise ValueError(f"Document type {document.type!r} has no index {index!r}") def filtered(change: CultMeshDatabaseChange) -> None: - if self._change_matches_extractor(change, extractor, value): + if self._change_matches_extractor(change, extractor, value, index_text): callback(self._change_as_document(change, requested)) return self.watch(filtered, document=document) @@ -1295,10 +1295,11 @@ class CultMeshDatabase: change: CultMeshDatabaseChange, extractor: str | Callable[[Any], str | int | float | bool | None], expected: str, + render: Callable[[Any], str | None] = str, ) -> bool: return ( - self._value_matches_extractor(change.value, extractor, expected) - or self._value_matches_extractor(change.previous_value, extractor, expected) + self._value_matches_extractor(change.value, extractor, expected, render) + or self._value_matches_extractor(change.previous_value, extractor, expected, render) ) def _value_matches_extractor( @@ -1306,11 +1307,12 @@ class CultMeshDatabase: value: Any | None, extractor: str | Callable[[Any], str | int | float | bool | None], expected: str, + render: Callable[[Any], str | None] = str, ) -> bool: if value is None: return False actual = extract_value(value, extractor) - return actual is not None and str(actual) == expected + return actual is not None and render(actual) == expected def _previous_values_for_snapshot_response(self, response: dict[str, Any]) -> dict[tuple[str, str], Any]: if response.get("schemaVersion") != "cultnet.snapshot_response_raw.v0": diff --git a/packages/cultmesh-py/tests/test_unique_index_watch.py b/packages/cultmesh-py/tests/test_unique_index_watch.py new file mode 100644 index 00000000..44c606ff --- /dev/null +++ b/packages/cultmesh-py/tests/test_unique_index_watch.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import re +import unittest +from pathlib import Path + +from cultcache_py import define_database_entry_type +from cultcache_py.cache import CultCacheError +from cultmesh_py import CultMesh + +# variants:ruling:unique-indexes. `indexes` are unique lookups, `roles` are selection aliases. A watch by index reads the +# lookup's own canonical text (cultcache_py.cache.index_text), never a second rendering of the value. + + +class WatchByIndexTests(unittest.TestCase): + def test_the_watch_and_the_lookup_agree_on_the_text_of_a_value(self) -> None: + document = define_database_entry_type("mesh.priced", [("price", 0)], indexes={"price": "price"}) + node = CultMesh.create_node(runtime_id="mesh-watch-text") + node.database.register_document(document) + cases = [ + (2.0, "2", "2.0"), + (0.1, "0.1000000000000000055511151231257827021181583404541015625", "0.1"), + (-0.0, "0", "-0.0"), + ] + for index, (value, text, str_text) in enumerate(cases): + with self.subTest(value=value): + key = f"item:{index}" + heard: list[str] = [] + misheard: list[str] = [] + node.database.watch_by_index(document, "price", text, lambda change: heard.append(change.record_key)) + node.database.watch_by_index(document, "price", str_text, lambda change: misheard.append(change.record_key)) + node.database.put(document, key, {"price": value}) + self.assertEqual(node.database.get_by_index(document, "price", text), {"price": value}) + self.assertEqual(heard, [key]) + if str_text != text: + self.assertEqual(misheard, []) + self.assertIsNone(node.database.get_by_index(document, "price", str_text)) + + def test_a_blank_value_is_never_indexed_so_never_watched(self) -> None: + document = define_database_entry_type("mesh.blank", [("sku", 0)], indexes={"sku": "sku"}) + node = CultMesh.create_node(runtime_id="mesh-watch-blank") + node.database.register_document(document) + heard: list[str] = [] + node.database.watch_by_index(document, "sku", " ", lambda change: heard.append(change.record_key)) + node.database.put(document, "item:1", {"sku": " "}) + self.assertEqual(heard, []) + + def test_a_role_alias_is_not_watchable_by_index(self) -> None: + document = define_database_entry_type("mesh.roles", [("kind", 0)], roles={"kind": "kind"}) + node = CultMesh.create_node(runtime_id="mesh-watch-role") + node.database.register_document(document) + with self.assertRaisesRegex(ValueError, "has no index"): + node.database.watch_by_index(document, "kind", "guide", lambda _: None) + + +class ReadmeTests(unittest.TestCase): + def test_the_readme_note_document_admits_many_notes_of_one_kind(self) -> None: + readme = (Path(__file__).parents[1] / "README.md").read_text(encoding="utf-8") + declaration = re.search(r"^note_doc = define_database_entry_type\(\n.*?^\)\n", readme, re.S | re.M) + self.assertIsNotNone(declaration) + scope: dict = {"define_database_entry_type": define_database_entry_type} + exec(declaration.group(0), scope) # noqa: S102 - the README's own declaration + note_doc = scope["note_doc"] + node = CultMesh.create_node(runtime_id="mesh-readme") + node.database.register_document(note_doc) + node.database.put(note_doc, "note:1", {"body": "a", "name": "one", "kind": "guide", "slug": "one"}) + node.database.put(note_doc, "note:2", {"body": "b", "name": "two", "kind": "guide", "slug": "two"}) + self.assertEqual(node.database.get_by_index(note_doc, "slug", "two")["body"], "b") + with self.assertRaises(CultCacheError): + node.database.put(note_doc, "note:3", {"body": "c", "name": "three", "kind": "guide", "slug": "one"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/cultnet-py/CHANGELOG.md b/packages/cultnet-py/CHANGELOG.md index 5e4b0cfa..e7bd3afd 100644 --- a/packages/cultnet-py/CHANGELOG.md +++ b/packages/cultnet-py/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased (breaking; version 0.x, so the next minor) +- `CultNetDatabaseChange` carries `commit_id` and `commit_count` (`commitId`/`commitCount`; None outside a multi-record commit, as C# writes nil): `from_wire` refuses an empty or non-string id and a count that is not an integer of at least 2, `to_wire` writes them when set, and the published `database_change_raw` schema lists both. Python applies no batch. The `simulation_fact` document's `shard`, `frame`, `subject` and `claim_kind` aliases are `roles` (many facts share each); the catalog bytes do not move. - Decodes the write receipt frames of `contracts/cultnet/interop/write-receipt.md`: `parse_message` validates `cultnet.write_admitted.v0` and `cultnet.error.v1` against their closed schemas (unknown keys, empty or non-string ids, an unknown operation or code are refused; `error.v1` requires `messageId`, string or null; `limit_reached` is `error.v1` only). Added `CULTNET_ERROR_V1_CODES`, `CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS`, `write_content_hash` and `hello_advertises_write_receipts`; `hello` takes `supported_message_versions`. `tests/test_write_receipt.py` replays the vectors' `contentHash`, `wire` and `schemaInvalid` sections. Not implemented here: the client write tracker (`scenarios`) and the server rules (`dedupe`, `blankFields`); this package has no receipt server and no write client. - Added `message_invalid` to `CULTNET_ERROR_CODES` and `message_invalid_error(field, reason)`, byte-identical to the C# `ForMessageInvalid`. `cultmesh-py` refuses a malformed put or delete with it instead of the out-of-schema `malformed_document_put` and `malformed_document_delete`, which are removed. - Added `CultNetRudpSocketTransportServer` (`cultnet_py.rudp_server`): one UDP socket serving many RUDP peers, one session per diff --git a/packages/cultnet-py/src/cultnet_py/cultmesh_contracts.py b/packages/cultnet-py/src/cultnet_py/cultmesh_contracts.py index 91d0805b..bc5c2622 100644 --- a/packages/cultnet-py/src/cultnet_py/cultmesh_contracts.py +++ b/packages/cultnet-py/src/cultnet_py/cultmesh_contracts.py @@ -692,7 +692,7 @@ simulation_fact_document: DocumentDefinition[CultMeshSimulationFact] = define_da ], cls=CultMeshSimulationFact, name="fact_id", - indexes={"shard": "shard_id", "frame": "frame", "subject": "subject_id", "claim_kind": "claim_kind"}, + roles={"shard": "shard_id", "frame": "frame", "subject": "subject_id", "claim_kind": "claim_kind"}, schema_name=SIMULATION_FACT_DOCUMENT_TYPE, schema_version=SIMULATION_FACT_SCHEMA_VERSION, ) diff --git a/packages/cultnet-py/src/cultnet_py/schema_catalog.py b/packages/cultnet-py/src/cultnet_py/schema_catalog.py index ee6fe430..85ab799d 100644 --- a/packages/cultnet-py/src/cultnet_py/schema_catalog.py +++ b/packages/cultnet-py/src/cultnet_py/schema_catalog.py @@ -432,6 +432,8 @@ def wire_message_schema_properties(schema_version: str) -> dict[str, Any]: "document": {"$ref": "#/$defs/rawDocumentRecord"}, "schemaId": common["schemaId"], "recordKey": common["recordKey"], + "commitId": {"type": ["string", "null"], "minLength": 1}, + "commitCount": {"type": ["integer", "null"], "minimum": 2}, }, "cultnet.shard_catalog_request.v0": { **common, diff --git a/packages/cultnet-py/src/cultnet_py/subscription.py b/packages/cultnet-py/src/cultnet_py/subscription.py index 6756376b..74c3be33 100644 --- a/packages/cultnet-py/src/cultnet_py/subscription.py +++ b/packages/cultnet-py/src/cultnet_py/subscription.py @@ -15,6 +15,10 @@ class CultNetDatabaseChange: raw_document: CultNetRawDocumentRecord | None = None schema_id: str | None = None record_key: str | None = None + commit_id: str | None = None + """Set on every change of one multi-record commit, the same for all of them; None outside one (C# writes nil).""" + commit_count: int | None = None + """How many changes the commit holds (>= 2); None outside one. Carried data: Python applies no batch.""" @classmethod def from_wire(cls, message: dict[str, Any]) -> "CultNetDatabaseChange": @@ -31,6 +35,12 @@ class CultNetDatabaseChange: if raw_document is not None: schema_id = schema_id or raw_document.schema_id record_key = record_key or raw_document.record_key + commit_id = message.get("commitId") + if commit_id is not None and not (isinstance(commit_id, str) and commit_id): + raise ValueError(f"database change commitId must be a non-empty string, received {commit_id!r}") + commit_count = message.get("commitCount") + if commit_count is not None and (isinstance(commit_count, bool) or not isinstance(commit_count, int) or commit_count < 2): + raise ValueError(f"database change commitCount must be an integer of at least 2, received {commit_count!r}") return cls( message_id=str(message.get("messageId") or ""), subscription_id=str(message.get("subscriptionId") or ""), @@ -39,6 +49,8 @@ class CultNetDatabaseChange: raw_document=raw_document, schema_id=schema_id, record_key=record_key, + commit_id=commit_id, + commit_count=commit_count, ) def to_wire(self) -> dict[str, Any]: @@ -56,6 +68,10 @@ class CultNetDatabaseChange: wire["schemaId"] = self.schema_id if self.record_key is not None: wire["recordKey"] = self.record_key + if self.commit_id is not None: + wire["commitId"] = self.commit_id + if self.commit_count is not None: + wire["commitCount"] = self.commit_count return wire diff --git a/packages/cultnet-py/tests/test_cultnet.py b/packages/cultnet-py/tests/test_cultnet.py index 6e8d2b71..5ece34be 100644 --- a/packages/cultnet-py/tests/test_cultnet.py +++ b/packages/cultnet-py/tests/test_cultnet.py @@ -1566,6 +1566,63 @@ class CultNetTests(unittest.TestCase): self.assertEqual(delete_change.schema_id, "schema-note") self.assertEqual(delete_change.to_wire()["recordKey"], "note:1") + def test_cultnet_database_change_commit_fields(self) -> None: + base = { + "schemaVersion": "cultnet.database_change_raw.v0", + "messageId": "change-commit", + "subscriptionId": "sub-1", + "changeKind": "removed", + "schemaId": "schema-note", + "recordKey": "note:1", + } + grouped = CultNetDatabaseChange.from_wire({**base, "commitId": "commit-7", "commitCount": 3}) + self.assertEqual((grouped.commit_id, grouped.commit_count), ("commit-7", 3)) + self.assertEqual(grouped.to_wire()["commitId"], "commit-7") + self.assertEqual(grouped.to_wire()["commitCount"], 3) + self.assertEqual(CultNetDatabaseChange.from_wire(grouped.to_wire()), grouped) + + for absent in (base, {**base, "commitId": None, "commitCount": None}): # C# writes nil outside a commit + change = CultNetDatabaseChange.from_wire(absent) + self.assertIsNone(change.commit_id) + self.assertIsNone(change.commit_count) + self.assertNotIn("commitId", change.to_wire()) + self.assertNotIn("commitCount", change.to_wire()) + + for field, bad in ( + ("commitId", ""), + ("commitId", 7), + ("commitCount", 1), + ("commitCount", 0), + ("commitCount", True), + ("commitCount", 2.0), + ("commitCount", "3"), + ): + with self.subTest(field=field, bad=bad), self.assertRaisesRegex(ValueError, field): + CultNetDatabaseChange.from_wire({**base, field: bad}) + + def test_a_two_change_commit_from_the_csharp_server_parses(self) -> None: + # CultNetDatabaseServer.DeliverCommit (CultNetDatabaseServer.cs:791-797) tags every change of a commit with its + # commitId and commitCount = the number of changes delivered, so a swap of two records arrives with commitCount 2. + # Shaped as the C# [MessagePackObject] writes it: every key present, nil where unset. + swap = [ + { + "schemaVersion": "cultnet.database_change_raw.v0", + "messageId": f"change-{index}", + "subscriptionId": "sub-1", + "changeKind": "updated", + "document": None, + "recordKey": key, + "schemaId": "schema-note", + "commitId": "9f1c0f0e4b7a4c1f9d2e", + "commitCount": 2, + } + for index, key in enumerate(("note:a", "note:b")) + ] + changes = [CultNetDatabaseChange.from_wire(message) for message in swap] + self.assertEqual([change.commit_count for change in changes], [2, 2]) + self.assertEqual({change.commit_id for change in changes}, {"9f1c0f0e4b7a4c1f9d2e"}) + self.assertEqual([change.to_wire()["commitCount"] for change in changes], [2, 2]) + def test_cultnet_raw_put_helper_carries_message_id(self) -> None: put = document_put_raw( message_id="put-1", @@ -2272,6 +2329,8 @@ class CultNetTests(unittest.TestCase): self.assertIn("document", change_schema["properties"]) self.assertIn("schemaId", change_schema["properties"]) self.assertIn("recordKey", change_schema["properties"]) + self.assertEqual(change_schema["properties"]["commitId"], {"type": ["string", "null"], "minLength": 1}) + self.assertEqual(change_schema["properties"]["commitCount"], {"type": ["integer", "null"], "minimum": 2}) log_schema = json.loads(by_version["cultnet.shard_log_response.v0"]["schemaJson"]) self.assertIn("entries", log_schema["required"]) From a7c22e6ee581ed8c40eeb1246dd60acf09ddb215 Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 07:35:33 +0000 Subject: [PATCH 11/93] cultmesh-browser test: make the unallocatable-size refusal certain on hosts that overcommit (#93) --- packages/cultmesh-browser/test/content.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/cultmesh-browser/test/content.test.ts b/packages/cultmesh-browser/test/content.test.ts index d4ec4d6e..2f77904e 100644 --- a/packages/cultmesh-browser/test/content.test.ts +++ b/packages/cultmesh-browser/test/content.test.ts @@ -567,8 +567,14 @@ test("a size the runtime cannot allocate is too-large in both exports, not a Ran const count = 2 ** 15; const huge = { ...manifest, sizeBytes: count * int32Max, chunks: Array.from({ length: count }, (_, index) => ({ ...manifest.chunks[0]!, offset: index * int32Max, sizeBytes: int32Max })) }; const server = double(() => { throw new Error("no request expected"); }); - await assert.rejects(fetchVerifiedContent(ENDPOINT, huge, { fetch: server.fetch, maxBytes: size }), codeOf("too-large")); - await assert.rejects(fetchVerifiedChunk(ENDPOINT, manifest.chunks[0]!.chunkHash!, size, { fetch: server.fetch, maxBytes: size }), codeOf("too-large")); + // a host with overcommit hands out 64 TiB lazily, so the refusal is made certain: the runtime cannot allocate it + const native = globalThis.Uint8Array; + const refusing = new Proxy(native, { construct: (target, args, receiver) => { if (typeof args[0] === "number" && args[0] > 2 ** 40) throw new RangeError("Array buffer allocation failed"); return Reflect.construct(target, args, receiver); } }); + globalThis.Uint8Array = refusing; + try { + await assert.rejects(fetchVerifiedContent(ENDPOINT, huge, { fetch: server.fetch, maxBytes: size }), codeOf("too-large")); + await assert.rejects(fetchVerifiedChunk(ENDPOINT, manifest.chunks[0]!.chunkHash!, size, { fetch: server.fetch, maxBytes: size }), codeOf("too-large")); + } finally { globalThis.Uint8Array = native; } assert.equal(server.calls.length, 0); }); From 43aa451fb9233c1f76d8e93ef52957675b4c9bbf Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 07:43:22 +0000 Subject: [PATCH 12/93] cultcache-rs: #[cultcache(index)] is unique at commit and load; re-registration re-judges held records (#95) --- packages/cultcache-rs/CHANGELOG.md | 6 +- packages/cultcache-rs/README.md | 4 +- .../cultcache-rs-derive/src/lib.rs | 34 +- packages/cultcache-rs/src/lib.rs | 194 ++++- packages/cultcache-rs/tests/lookups.rs | 144 ++-- packages/cultcache-rs/tests/unique_index.rs | 669 ++++++++++++++++++ 6 files changed, 960 insertions(+), 91 deletions(-) create mode 100644 packages/cultcache-rs/tests/unique_index.rs diff --git a/packages/cultcache-rs/CHANGELOG.md b/packages/cultcache-rs/CHANGELOG.md index c417d535..f136bccf 100644 --- a/packages/cultcache-rs/CHANGELOG.md +++ b/packages/cultcache-rs/CHANGELOG.md @@ -10,10 +10,14 @@ only in git history. - A single-file store written as `cultcache.store.v3` is read and written: a record that holds an element id (an owned list element with an `[CultElementId]` member, carried through every write) marks the store v3, and a v3 store is carried through a rewrite with its ids. A runtime from before this change refuses a v3 store by name. - A write over a file the reader refuses (a zero-byte file, a truncated store, a stamp this runtime does not read) refuses with `CultCacheStoreUnreadable` and leaves the file as it is. A whole-store write used to replace such a file. This reverses 0.3.1, where `push_all` replaced a file with no readable stamp (healing it); the stamp rule now has one owner, the reader every write asks under the lock, and it accepts v1 and v3 (0.3.1 refused v3). +- `#[cultcache(index)]` means unique, as in C# `RefuseIndexSharing`: within one type and one alias no two records hold one non-blank value. A put, batch, push or load that would make two records hold a value is refused, naming both keys and the alias, and a store that already holds a duplicate does not open. A grouping index is no longer expressible; a consumer that relied on shared values must drop the `index` and look up by another way. +- `bool` index and lookup text renders `True` / `False`, as C# does, where it rendered `true` / `false`. +- `DatabaseEntry::UNIQUE_INDEXES` replaces `cult_indexes` as the one index declaration and changes type (its members are the unique aliases); a hand implementation overriding `cult_indexes` no longer compiles. +- `register_entry_type` over a type that already holds records re-judges them under the new declaration (unique indexes and the global rule) and refuses, changing nothing, when they violate it. A declaration with an index also refuses when a held record cannot be decoded as the new type; a declaration without one does not read the records, so it does not. ### Added -- Lookups, matching the C# `GetByName`, `GetByIndex` and `GetGlobal`. `#[cultcache(name)]` and `#[cultcache(index)]` / `#[cultcache(index = "alias")]` on `DatabaseEntry` fields and `#[cultcache(global)]` on the struct give `CultCache::get_by_name`, `get_by_index` and `get_global`. An empty name and a blank index value are not found; records of one type sharing a name or value find the one with the smallest key. A second record of a global type under another key is refused on every write path and on load with `CultCacheSecondGlobal`. Two `name` members, or two members with one index alias, are refused at compile time. A member's text comes from `CultLookupValue` (`String`, `Option` of one, integers, `bool`, `char`). +- Lookups, matching the C# `GetByName`, `GetByIndex` and `GetGlobal`. `#[cultcache(name)]` and `#[cultcache(index)]` / `#[cultcache(index = "alias")]` on `DatabaseEntry` fields and `#[cultcache(global)]` on the struct give `CultCache::get_by_name`, `get_by_index` and `get_global`. An empty name and a blank index value are not found; records of one type sharing a name find the one with the smallest key (an index value is unique, below). A second record of a global type under another key is refused on every write path and on load with `CultCacheSecondGlobal`. Two `name` members, or two members with one index alias, are refused at compile time. A member's text comes from `CultLookupValue` (`String`, `Option` of one, integers, `bool`, `char`). - `SchemaConflictError`, the typed refusal of a write the catalog cannot describe: records of different types under one schema id, or an arrived schema and a registered one that share an id and disagree on the schema name. A whole-store write (`push_all`) refuses with it, names the schema id, the names involved and a record key, writes nothing and leaves the file as it is. A caller tells it apart with `error.downcast_ref::()`. ## [0.3.1] diff --git a/packages/cultcache-rs/README.md b/packages/cultcache-rs/README.md index 2c5c8027..1dab4fc7 100644 --- a/packages/cultcache-rs/README.md +++ b/packages/cultcache-rs/README.md @@ -412,7 +412,9 @@ fn outcome(error: &anyhow::Error) { 3. **Name and index lookups** - `cache.get_by_name::("Potion")` - - `cache.get_by_index::("faction", "Lucent")` + - `cache.get_by_index::("callsign", "Lucent-7")`: `#[cultcache(index)]` is unique. Within one type no two + records hold one non-blank value of the alias; a put, batch or load that would make two is refused, naming both + keys and the alias, and writes nothing. A grouping (faction, kind, status) is not an index: filter `get_all`. - generated extractors instead of reflection strings 4. **Schema/version metadata** diff --git a/packages/cultcache-rs/cultcache-rs-derive/src/lib.rs b/packages/cultcache-rs/cultcache-rs-derive/src/lib.rs index fe4e57c6..c7f9a3a2 100644 --- a/packages/cultcache-rs/cultcache-rs-derive/src/lib.rs +++ b/packages/cultcache-rs/cultcache-rs-derive/src/lib.rs @@ -69,23 +69,16 @@ fn database_entry_codec(input: DeriveInput) -> syn::Result = slots.iter().filter_map(|slot| slot.index.as_ref().map(|alias| (alias, &slot.ident))).collect(); + let index_rows = index_members.iter().map(|(alias, member)| { quote! { - if let ::std::option::Option::Some(value) = ::cultcache_rs::CultLookupValue::lookup_value(&self.#member) { - held.push((#alias, value)); - } + (#alias, (|record: &Self| ::cultcache_rs::CultLookupValue::lookup_value(&record.#member)) + as fn(&Self) -> ::std::option::Option<::std::string::String>) } }); - let index_pushes: Vec<_> = index_pushes.collect(); - let indexes_fn = (!index_pushes.is_empty()).then(|| { - quote! { - fn cult_indexes(&self) -> ::std::vec::Vec<(&'static str, ::std::string::String)> { - let mut held = ::std::vec::Vec::new(); - #(#index_pushes)* - held - } - } + let index_rows: Vec<_> = index_rows.collect(); + let unique_indexes = (!index_rows.is_empty()).then(|| { + quote! { const UNIQUE_INDEXES: &'static [::cultcache_rs::CultIndexMember] = &[#(#index_rows),*]; } }); Ok(quote! { @@ -93,8 +86,8 @@ fn database_entry_codec(input: DeriveInput) -> syn::Result, } @@ -941,15 +935,15 @@ mod tests { ); assert!(tokens.contains("constGLOBAL:bool=true"), "{tokens}"); assert!(tokens.contains("fncult_name"), "{tokens}"); - assert!(tokens.contains("(\"code\",value)"), "{tokens}"); - assert!(tokens.contains("(\"sku\",value)"), "{tokens}"); + assert!(tokens.contains("(\"code\","), "{tokens}"); + assert!(tokens.contains("(\"sku\","), "{tokens}"); } #[test] fn a_record_without_lookups_generates_none_of_them() { let tokens = entry_tokens(r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0)] a: String }"#); assert!(tokens.contains("constGLOBAL:bool=false"), "{tokens}"); - assert!(!tokens.contains("fncult_name") && !tokens.contains("fncult_indexes"), "{tokens}"); + assert!(!tokens.contains("fncult_name") && !tokens.contains("UNIQUE_INDEXES"), "{tokens}"); } // ResolveIndexAlias: a blank alias is the member's name. @@ -958,7 +952,7 @@ mod tests { let tokens = entry_tokens( r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0, index = " ")] code: String }"#, ); - assert!(tokens.contains("(\"code\",value)"), "{tokens}"); + assert!(tokens.contains("(\"code\","), "{tokens}"); } #[test] diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index ce7756e3..11d21330 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -168,12 +168,14 @@ pub trait DatabaseEntry: Serialize + DeserializeOwned + Clone + Send + 'static { fn cult_name(&self) -> Option { None } - /// Each `#[cultcache(index)]` member as (alias, value), which `get_by_index` finds the record by. - fn cult_indexes(&self) -> Vec<(&'static str, String)> { - Vec::new() - } + /// The `#[cultcache(index)]` members, the one declaration: each is an alias with the reader of its value. Each is + /// unique: within one type no two records hold one non-blank value of it, and `get_by_index` finds the record by it. + const UNIQUE_INDEXES: &'static [CultIndexMember] = &[]; } +/// An index alias and how to read its value from a record; `None` is no value. +pub type CultIndexMember = (&'static str, fn(&T) -> Option); + /// What a `#[cultcache(name)]` or `#[cultcache(index)]` member says it is called: its text, or `None` for a member /// that holds no value (an `Option` that is `None`). The cache treats an empty name and a blank index value as none. pub trait CultLookupValue { @@ -192,6 +194,13 @@ impl CultLookupValue for Option { } } +/// C# renders a boolean `True` or `False`; the index text is shared across runtimes. +impl CultLookupValue for bool { + fn lookup_value(&self) -> Option { + Some(if *self { "True" } else { "False" }.to_string()) + } +} + macro_rules! lookup_value_by_display { ($($ty:ty),* $(,)?) => {$( impl CultLookupValue for $ty { @@ -202,7 +211,7 @@ macro_rules! lookup_value_by_display { )*}; } -lookup_value_by_display!(bool, char, i8, i16, i32, i64, i128, isize, u8, u16, u32, u64, u128, usize); +lookup_value_by_display!(char, i8, i16, i32, i64, i128, isize, u8, u16, u32, u64, u128, usize); /// A global type already holds a record under another key; a second would leave `get_global` with two answers. #[derive(Debug, Clone, PartialEq, Eq)] @@ -2719,9 +2728,46 @@ pub struct CultCache { globals: BTreeSet, schema_name_definitions: BTreeMap, entries: BTreeMap<(String, String), CultCacheEnvelope>, + /// Derived from `entries` by the uniqueness judge: (type, alias, value) -> the one key holding that value. It + /// decides nothing; `get_by_index` reads it. + index_holders: IndexHolders, + /// Per registered type with `UNIQUE_INDEXES`: decodes a payload as that type and returns its non-blank index + /// values. Registration data, so the judge decodes only indexed types. + projectors: BTreeMap, stores: Vec, } +type IndexProjector = fn(&[u8]) -> Result>; +type IndexSlot = (String, String, String); +type IndexHolders = BTreeMap; + +fn project_indexes(payload: &[u8]) -> Result> { + let document = decode_payload::(payload)?; + Ok(T::UNIQUE_INDEXES + .iter() + .filter_map(|(alias, read)| read(&document).map(|value| (*alias, value))) + .filter(|(_, value)| !value.trim().is_empty()) + .collect()) +} + +/// What an admission does to the holder map: slots it releases (still held by the key that held them) and slots it takes. +#[derive(Default)] +struct IndexChange { + released: Vec<(IndexSlot, String)>, + taken: Vec<(IndexSlot, String)>, +} + +impl IndexChange { + fn apply(self, holders: &mut IndexHolders) { + for (slot, key) in self.released { + if holders.get(&slot) == Some(&key) { + holders.remove(&slot); + } + } + holders.extend(self.taken); + } +} + impl CultCache { pub fn new() -> Self { Self { @@ -2729,6 +2775,8 @@ impl CultCache { globals: BTreeSet::new(), schema_name_definitions: BTreeMap::new(), entries: BTreeMap::new(), + index_holders: BTreeMap::new(), + projectors: BTreeMap::new(), stores: Vec::new(), } } @@ -2756,12 +2804,40 @@ impl CultCache { existing_type )); } - self.definitions.insert(T::TYPE.to_string(), T::SCHEMA_NAME); + // The declaration decides what the held records hold (index projector, global flag), so a changed declaration + // re-judges them; a held violation (or a record the new type cannot decode) refuses the registration and + // changes nothing. + let previous = self.projectors.get(T::TYPE).copied(); + let was_global = self.globals.contains(T::TYPE); + if T::UNIQUE_INDEXES.is_empty() { + self.projectors.remove(T::TYPE); + } else { + self.projectors.insert(T::TYPE.to_string(), project_indexes::); + } if T::GLOBAL { self.globals.insert(T::TYPE.to_string()); } else { self.globals.remove(T::TYPE); } + let held_type = self.entries.range((T::TYPE.to_string(), String::new())..).next().is_some_and(|((held, _), _)| held == T::TYPE); + if held_type { + match self.refuse_second_globals(&self.entries).and_then(|()| self.judge_image(&self.entries)) { + Ok(holders) => self.index_holders = holders, + Err(refusal) => { + match previous { + Some(project) => self.projectors.insert(T::TYPE.to_string(), project), + None => self.projectors.remove(T::TYPE), + }; + if was_global { + self.globals.insert(T::TYPE.to_string()); + } else { + self.globals.remove(T::TYPE); + } + return Err(refusal); + } + } + } + self.definitions.insert(T::TYPE.to_string(), T::SCHEMA_NAME); self.schema_name_definitions .insert(T::SCHEMA_NAME.to_string(), T::TYPE.to_string()); Ok(()) @@ -2869,7 +2945,9 @@ impl CultCache { } } self.refuse_second_globals(&loaded)?; + let holders = self.judge_image(&loaded)?; self.entries = loaded; + self.index_holders = holders; Ok(()) } @@ -2973,20 +3051,18 @@ impl CultCache { self.find_record::(|document| document.cult_name().as_deref() == Some(name)) } - /// The record whose `#[cultcache(index)]` member `alias` holds `value`. A blank value is not indexed, so it finds - /// nothing. Records of one type sharing a value find the one with the smallest key (ordinal), whatever order they - /// were written or loaded in; removing it finds the next. + /// The record whose `#[cultcache(index)]` member `alias` holds `value`: at most one, since an index is unique. A + /// blank value is not indexed, so it finds nothing; neither does an alias `T` does not declare. pub fn get_by_index(&self, alias: &str, value: &str) -> Result> { self.require_entry_type::()?; - if value.trim().is_empty() { + if value.trim().is_empty() || !T::UNIQUE_INDEXES.iter().any(|(declared, _)| *declared == alias) { return Ok(None); } - self.find_record::(|document| { - document - .cult_indexes() - .iter() - .any(|(held_alias, held_value)| *held_alias == alias && held_value == value) - }) + let slot = (T::TYPE.to_string(), alias.to_string(), value.to_string()); + match self.index_holders.get(&slot) { + Some(key) => self.get::(key), + None => Ok(None), + } } /// The first record of `T`, in key order, that `matches`. @@ -3029,10 +3105,12 @@ impl CultCache { pub fn put(&mut self, key: impl Into, value: &T) -> Result { let (entry, parsed) = self.prepare_entry(key, value)?; self.refuse_second_global(&self.entries, &entry.r#type, &entry.key)?; + let change = self.judge_unique(&[&entry])?; if let Some(home) = self.home_index(T::TYPE)? { self.stores[home].store.push(&entry)?; } self.entries.insert(entry_id(&entry), entry); + change.apply(&mut self.index_holders); Ok(parsed) } @@ -3141,6 +3219,7 @@ impl CultCache { next.insert(entry_id(entry), entry.clone()); } self.refuse_second_globals(&next)?; + let change = self.judge_unique(&staged.iter().collect::>())?; if let Some(store_index) = store_index { // The home store receives its own records only: those it already // holds in this cache plus the staged ones. @@ -3154,16 +3233,19 @@ impl CultCache { .push_all(&snapshot, PushAllOptions::default())?; } self.entries = next; + change.apply(&mut self.index_holders); Ok(()) } pub fn put_envelope(&mut self, entry: CultCacheEnvelope) -> Result { let parsed = self.validate_envelope::(&entry)?; self.refuse_second_global(&self.entries, &entry.r#type, &entry.key)?; + let change = self.judge_unique(&[&entry])?; if let Some(home) = self.home_index(T::TYPE)? { self.stores[home].store.push(&entry)?; } self.entries.insert(entry_id(&entry), entry); + change.apply(&mut self.index_holders); Ok(parsed) } @@ -3193,10 +3275,12 @@ impl CultCache { } require_payload_depth(&entry)?; self.refuse_second_global(&self.entries, &entry.r#type, &entry.key)?; + let change = self.judge_unique(&[&entry])?; if let Some(home) = self.home_index(&entry.r#type)? { self.stores[home].store.push(&entry)?; } self.entries.insert(entry_id(&entry), entry); + change.apply(&mut self.index_holders); Ok(()) } @@ -3247,10 +3331,12 @@ impl CultCache { let Some(entry) = self.entries.get(&id).cloned() else { return Ok(false); }; + let released = self.projected_slots(&entry)?; if let Some(home) = self.home_index(T::TYPE)? { self.stores[home].store.delete(&entry)?; } self.entries.remove(&id); + IndexChange { released, taken: Vec::new() }.apply(&mut self.index_holders); Ok(true) } @@ -3279,10 +3365,86 @@ impl CultCache { loaded.insert(entry_id(&entry), entry); } self.refuse_second_globals(&loaded)?; + let holders = self.judge_image(&loaded)?; self.entries = loaded; + self.index_holders = holders; Ok(()) } + /// The slots an envelope holds: its type's non-blank index values, each held by its key. + fn projected_slots(&self, entry: &CultCacheEnvelope) -> Result> { + let Some(project) = self.projectors.get(&entry.r#type) else { + return Ok(Vec::new()); + }; + let held = project(&entry.payload).with_context(|| { + format!("failed to project the unique indexes of CultCache entry {:?} at key {:?}", entry.r#type, entry.key) + })?; + Ok(held + .into_iter() + .map(|(alias, value)| ((entry.r#type.clone(), alias.to_string(), value), entry.key.clone())) + .collect()) + } + + /// The one index rule (C# `RefuseIndexSharing`), judged on the result of an admission: within one type and one + /// unique alias, no two records hold one non-blank value. The holders of a value are the held key (unless this + /// admission rewrites it) plus every landing key projecting it; two distinct holders refuse, naming both keys + /// (ordinal) and the alias, never the value. A key landing twice lands as its last envelope. + fn judge_unique(&self, landing: &[&CultCacheEnvelope]) -> Result { + self.judge_against(&self.index_holders, landing, true) + } + + /// Judges a whole image against an empty holder map and returns the map it installs. + fn judge_image(&self, image: &BTreeMap<(String, String), CultCacheEnvelope>) -> Result { + let landing: Vec<_> = image.values().collect(); + let mut holders = IndexHolders::new(); + self.judge_against(&holders, &landing, false)?.apply(&mut holders); + Ok(holders) + } + + fn judge_against(&self, held: &IndexHolders, landing: &[&CultCacheEnvelope], replacing: bool) -> Result { + let mut last: BTreeMap<(&str, &str), &CultCacheEnvelope> = BTreeMap::new(); + for entry in landing { + last.insert((entry.r#type.as_str(), entry.key.as_str()), entry); + } + let mut change = IndexChange::default(); + let mut groups: Vec<(IndexSlot, BTreeSet)> = Vec::new(); + let mut at: BTreeMap = BTreeMap::new(); + for entry in last.values() { + if !self.projectors.contains_key(&entry.r#type) { + continue; + } + if replacing && let Some(old) = self.entries.get(&entry_id(entry)) { + change.released.extend(self.projected_slots(old)?); + } + for (slot, key) in self.projected_slots(entry)? { + let index = *at.entry(slot.clone()).or_insert_with(|| { + groups.push((slot, BTreeSet::new())); + groups.len() - 1 + }); + groups[index].1.insert(key); + } + } + groups.sort_by(|left, right| left.0.1.cmp(&right.0.1)); + for (slot, mut holders) in groups { + if let Some(held_key) = held.get(&slot) + && !last.contains_key(&(slot.0.as_str(), held_key.as_str())) + { + holders.insert(held_key.clone()); + } + let mut ordered = holders.iter(); + if let (Some(first), Some(second)) = (ordered.next(), ordered.next()) { + return Err(anyhow!( + "Records {first} and {second} ({}) both hold unique index '{}'; an index value names one record.", + self.definitions.get(&slot.0).copied().unwrap_or(&slot.0), + slot.1 + )); + } + let key = holders.into_iter().next().expect("a group holds at least one key"); + change.taken.push((slot, key)); + } + Ok(change) + } + fn refuse_second_global( &self, entries: &BTreeMap<(String, String), CultCacheEnvelope>, diff --git a/packages/cultcache-rs/tests/lookups.rs b/packages/cultcache-rs/tests/lookups.rs index ca5b459d..29aa3489 100644 --- a/packages/cultcache-rs/tests/lookups.rs +++ b/packages/cultcache-rs/tests/lookups.rs @@ -11,7 +11,7 @@ use cultcache_rs::{ struct Item { #[cultcache(key = 0, name)] name: String, - #[cultcache(key = 1, index)] + #[cultcache(key = 1)] category: String, #[cultcache(key = 2, index = "sku")] code: Option, @@ -30,7 +30,7 @@ struct Gadget { #[cultcache(key = 0, name)] name: String, #[cultcache(key = 1, index)] - category: String, + serial: String, } /// Sorts after `Item` and has its exact shape: a lookup that ran past the end of its own type would answer from it. @@ -39,7 +39,7 @@ struct Gadget { struct Zed { #[cultcache(key = 0, name)] name: String, - #[cultcache(key = 1, index)] + #[cultcache(key = 1)] category: String, #[cultcache(key = 2, index = "sku")] code: Option, @@ -48,7 +48,7 @@ struct Zed { } #[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache(type = "settings", global)] +#[cultcache(type = "settings", schema = "tests.settings", global)] struct Settings { #[cultcache(key = 0)] value: String, @@ -71,7 +71,7 @@ struct Numbered { #[cultcache(key = 0, name)] id: u32, #[cultcache(key = 1, index)] - owner: u64, + badge: u64, } fn cache() -> CultCache { @@ -110,7 +110,6 @@ fn a_record_is_found_by_name_and_by_index_after_a_reopen() { let reopened = reopened(&path); let by_name = reopened.get_by_name::("Potion").unwrap().unwrap(); assert_eq!(by_name.value, 50); - assert_eq!(reopened.get_by_index::("category", "Consumable").unwrap(), Some(by_name.clone())); assert_eq!(reopened.get_by_index::("sku", "P-1").unwrap(), Some(by_name)); } @@ -121,31 +120,32 @@ fn a_missing_name_or_value_is_none_and_an_unregistered_type_is_refused() { assert_eq!(cache.get_by_name::("B").unwrap(), None); assert_eq!(cache.get_by_name::("a").unwrap(), None, "names compare ordinally"); - assert_eq!(cache.get_by_index::("category", "other").unwrap(), None); - assert_eq!(cache.get_by_index::("nosuchalias", "cat").unwrap(), None); + assert_eq!(cache.get_by_index::("sku", "other").unwrap(), None); + assert_eq!(cache.get_by_index::("nosuchalias", "a-1").unwrap(), None); + assert_eq!(cache.get_by_index::("category", "cat").unwrap(), None, "category is a grouping, not an index"); assert_eq!(cache.get_by_index::("code", "a-1").unwrap(), None, "the alias is sku, not the member name"); let bare = CultCache::new(); assert!(bare.get_by_name::("A").is_err()); - assert!(bare.get_by_index::("category", "cat").is_err()); + assert!(bare.get_by_index::("sku", "a-1").is_err()); assert!(bare.get_global::().is_err()); } #[test] fn an_index_without_an_alias_is_named_by_its_member() { let mut cache = cache(); - cache.put("item:a", &item("A", "cat", None)).unwrap(); - assert!(cache.get_by_index::("category", "cat").unwrap().is_some()); + cache.put("numbered:1", &Numbered { id: 1, badge: 7 }).unwrap(); + assert!(cache.get_by_index::("badge", "7").unwrap().is_some()); } // A blank name is no name; a blank index value is not indexed; a nil member holds no value. #[test] fn blank_and_nil_values_are_not_found() { let mut cache = cache(); - cache.put("item:blank", &item("", " ", None)).unwrap(); + cache.put("item:blank", &item("", " ", Some(" "))).unwrap(); assert_eq!(cache.get_by_name::("").unwrap(), None); - assert_eq!(cache.get_by_index::("category", " ").unwrap(), None); - assert_eq!(cache.get_by_index::("category", "").unwrap(), None); + assert_eq!(cache.get_by_index::("sku", " ").unwrap(), None); + assert_eq!(cache.get_by_index::("sku", "").unwrap(), None); assert_eq!(cache.get_by_index::("sku", "").unwrap(), None); // Only emptiness voids a name: whitespace is a name, as in C# (Length > 0). @@ -156,68 +156,41 @@ fn blank_and_nil_values_are_not_found() { #[test] fn lookups_never_cross_types() { let mut cache = cache(); - cache.put("item:a", &item("same", "same", None)).unwrap(); - cache.put("gadget:a", &Gadget { name: "same".into(), category: "same".into() }).unwrap(); + cache.put("item:a", &item("same", "same", Some("same"))).unwrap(); + cache.put("gadget:a", &Gadget { name: "same".into(), serial: "same".into() }).unwrap(); cache.put("zed:a", &Zed { name: "same".into(), category: "same".into(), code: None, value: 0 }).unwrap(); assert_eq!(cache.get_by_name::("same").unwrap().unwrap().name, "same"); cache.delete::("item:a").unwrap(); assert_eq!(cache.get_by_name::("same").unwrap(), None); - assert_eq!(cache.get_by_index::("category", "same").unwrap(), None); + assert_eq!(cache.get_by_index::("sku", "same").unwrap(), None); assert!(cache.get_by_name::("same").unwrap().is_some()); - assert!(cache.get_by_index::("category", "same").unwrap().is_some()); + assert!(cache.get_by_index::("serial", "same").unwrap().is_some()); } #[test] fn a_name_and_index_follow_a_rewritten_record_and_leave_a_removed_one() { let mut cache = cache(); - cache.put("item:a", &item("old", "c1", None)).unwrap(); - cache.put("item:a", &item("new", "c2", None)).unwrap(); + cache.put("item:a", &item("old", "cat", Some("c1"))).unwrap(); + cache.put("item:a", &item("new", "cat", Some("c2"))).unwrap(); assert_eq!(cache.get_by_name::("old").unwrap(), None, "a stale name is not found"); - assert_eq!(cache.get_by_index::("category", "c1").unwrap(), None, "a stale value is not found"); + assert_eq!(cache.get_by_index::("sku", "c1").unwrap(), None, "a stale value is not found"); assert!(cache.get_by_name::("new").unwrap().is_some()); - assert!(cache.get_by_index::("category", "c2").unwrap().is_some()); + assert!(cache.get_by_index::("sku", "c2").unwrap().is_some()); cache.delete::("item:a").unwrap(); assert_eq!(cache.get_by_name::("new").unwrap(), None); - assert_eq!(cache.get_by_index::("category", "c2").unwrap(), None); + assert_eq!(cache.get_by_index::("sku", "c2").unwrap(), None); } #[test] fn non_string_members_are_found_by_their_text() { let mut cache = cache(); - let owner = 161_u64; - cache.put("numbered:1", &Numbered { id: 42, owner }).unwrap(); + let badge = 161_u64; + cache.put("numbered:1", &Numbered { id: 42, badge }).unwrap(); assert!(cache.get_by_name::("42").unwrap().is_some()); - assert!(cache.get_by_index::("owner", "161").unwrap().is_some()); -} - -// VariantTests.RemovingTheHolderADuplicateValueFindsLeavesTheOtherFindable: records sharing a value find the one with -// the smallest key, whatever the write order, live and after a reopen; removing the holder finds the other. -#[test] -fn records_sharing_a_value_find_the_smallest_key_and_removing_it_finds_the_next() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("shared.cc"); - { - let mut cache = stored_cache(&path); - cache.put("item:p2", &item("p2", "x", None)).unwrap(); - cache.put("item:p1", &item("p1", "x", None)).unwrap(); - let found = |cache: &CultCache| cache.get_by_index::("category", "x").unwrap().map(|found| found.name); - assert_eq!(found(&cache).as_deref(), Some("p1"), "the smallest key, though written second"); - assert_eq!(reopened(&path).get_by_index::("category", "x").unwrap().unwrap().name, "p1"); - - cache.delete::("item:p1").unwrap(); - assert_eq!(found(&cache).as_deref(), Some("p2"), "removing the holder finds the other one"); - - cache.put("item:p1", &item("p1", "x", None)).unwrap(); - assert_eq!(found(&cache).as_deref(), Some("p1")); - cache.delete::("item:p2").unwrap(); - assert_eq!(found(&cache).as_deref(), Some("p1"), "removing the other changes nothing"); - cache.delete::("item:p1").unwrap(); - assert_eq!(found(&cache), None); - } - assert_eq!(reopened(&path).get_by_index::("category", "x").unwrap(), None); + assert!(cache.get_by_index::("badge", "161").unwrap().is_some()); } #[test] @@ -244,6 +217,71 @@ fn a_global_is_none_until_written_and_survives_a_reopen() { assert_eq!(reopened(&path).get_global::().unwrap(), Some(settings("durable authority"))); } +/// `Settings` as an ordinary type: the same type id and schema, no global rule. +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "settings", schema = "tests.settings")] +struct LooseSettings { + #[cultcache(key = 0)] + value: String, +} + +// Declaring a held type global judges the records it holds: two are already a second, so the registration is +// refused and the type stays ordinary. +#[test] +fn registering_a_type_global_over_two_held_records_is_refused_and_changes_nothing() { + let mut cache = CultCache::new(); + cache.register_entry_type::().unwrap(); + cache.put("a", &LooseSettings { value: "one".into() }).unwrap(); + cache.put("b", &LooseSettings { value: "two".into() }).unwrap(); + + let refusal = cache.register_entry_type::().unwrap_err(); + assert!(refusal.downcast_ref::().is_some(), "{refusal:#}"); + + cache.put("c", &LooseSettings { value: "three".into() }).unwrap(); + assert_eq!(cache.get_all_with_keys::().unwrap().len(), 3, "still ordinary"); +} + +// One held record may become the global; a second is then refused as a second global. +#[test] +fn registering_a_type_global_over_one_held_record_is_admitted() { + let mut cache = CultCache::new(); + cache.register_entry_type::().unwrap(); + cache.put("a", &LooseSettings { value: "one".into() }).unwrap(); + cache.register_entry_type::().unwrap(); + let refusal = cache.put("b", &Settings { value: "two".into() }).unwrap_err(); + assert!(refusal.downcast_ref::().is_some(), "{refusal:#}"); +} + +/// A global with an indexed member, and the same type and schema declaring that member as an integer. +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "indexed_global", schema = "tests.indexed_global", global)] +struct IndexedGlobal { + #[cultcache(key = 0, index = "V")] + v: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "indexed_global", schema = "tests.indexed_global", global)] +struct RetypedGlobal { + #[cultcache(key = 0, index = "V")] + v: i64, +} + +// A refused re-registration over an already-global type leaves it global: the rule keeps refusing a second record. +#[test] +fn a_refused_reregistration_leaves_a_global_type_global() { + let mut cache = CultCache::new(); + cache.register_entry_type::().unwrap(); + cache.put("a", &IndexedGlobal { v: "x".into() }).unwrap(); + + assert!(cache.register_entry_type::().is_err(), "the held text cannot be decoded as an integer"); + + let refusal = cache.put("b", &IndexedGlobal { v: "y".into() }).unwrap_err(); + assert!(refusal.downcast_ref::().is_some(), "{refusal:#}"); + assert_eq!(cache.get_global::().unwrap(), Some(IndexedGlobal { v: "x".into() })); + assert_eq!(cache.get_by_index::("V", "x").unwrap(), cache.get::("a").unwrap()); +} + #[test] fn a_type_that_is_not_a_global_has_no_global() { let mut cache = cache(); diff --git a/packages/cultcache-rs/tests/unique_index.rs b/packages/cultcache-rs/tests/unique_index.rs new file mode 100644 index 00000000..d0da4175 --- /dev/null +++ b/packages/cultcache-rs/tests/unique_index.rs @@ -0,0 +1,669 @@ +//! `#[cultcache(index)]` is unique (`variants:ruling:unique-indexes`; C# `RefuseIndexSharing`): within one type and one +//! alias no two records hold one non-blank value, judged on the result of each admission, on every write path and every +//! load. The refusal text is informational; the contract is the verdict, so most pins assert the verdict and the +//! shared vectors in `tests/vectors/unique-index` pin the text and the canonical index text. + +use std::path::{Path, PathBuf}; + +use cultcache_rs::{ + CultCache, CultCacheEnvelope, CultCacheSoaTable, DatabaseEntry, SingleFileMessagePackBackingStore, +}; +use serde::Deserialize; + +/// The vectors' `tests.unique_item`: Sku is unique, Category a grouping. +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "unique_item", schema = "tests.unique_item")] +struct Unique { + #[cultcache(key = 0, index = "Sku")] + sku: String, + #[cultcache(key = 1)] + category: String, +} + +/// The same type with no index: a role, which writes the duplicates `Unique` refuses. +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "unique_item", schema = "tests.unique_item")] +struct Twin { + #[cultcache(key = 0)] + sku: String, + #[cultcache(key = 1)] + category: String, +} + +/// Two unique indexes declared out of alphabetical order, so no ordering is an accident of declaration. +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "multi", schema = "Multi")] +struct Multi { + #[cultcache(key = 0, index = "B")] + b: Option, + #[cultcache(key = 1, index = "A")] + a: Option, +} + +/// Another type sharing the alias `Sku`: types never conflict. +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "other", schema = "Other")] +struct Other { + #[cultcache(key = 0, index = "Sku")] + sku: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "plain", schema = "Plain")] +struct Plain { + #[cultcache(key = 0)] + value: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "vs", schema = "VS")] +struct VString { + #[cultcache(key = 0, index = "V")] + v: String, +} +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "vi", schema = "VI")] +struct VInt { + #[cultcache(key = 0, index = "V")] + v: i64, +} +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "vu", schema = "VU")] +struct VUint { + #[cultcache(key = 0, index = "V")] + v: u64, +} +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "vb", schema = "VB")] +struct VBool { + #[cultcache(key = 0, index = "V")] + v: bool, +} + +fn unique(sku: &str) -> Unique { + Unique { sku: sku.into(), category: "c".into() } +} + +fn multi(a: Option<&str>, b: Option<&str>) -> Multi { + Multi { a: a.map(Into::into), b: b.map(Into::into) } +} + +fn refusal(k1: &str, k2: &str, schema: &str, alias: &str) -> String { + format!("Records {k1} and {k2} ({schema}) both hold unique index '{alias}'; an index value names one record.") +} + +fn cache_at(path: &Path) -> CultCache { + let mut cache = CultCache::new(); + cache.register_entry_type::().unwrap(); + cache.register_entry_type::().unwrap(); + cache.register_entry_type::().unwrap(); + cache.register_entry_type::().unwrap(); + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(path)).unwrap(); + cache.pull_all_backing_stores().unwrap(); + cache +} + +fn memory_cache() -> CultCache { + let mut cache = CultCache::new(); + cache.register_entry_type::().unwrap(); + cache.register_entry_type::().unwrap(); + cache.register_entry_type::().unwrap(); + cache +} + +fn twin_cache_at(path: &Path) -> CultCache { + let mut cache = CultCache::new(); + cache.register_entry_type::().unwrap(); + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(path)).unwrap(); + cache.pull_all_backing_stores().unwrap(); + cache +} + +fn error_text(result: anyhow::Result) -> String { + format!("{:#}", result.unwrap_err()) +} + +fn skus(cache: &CultCache) -> Vec<(String, String)> { + cache.get_all_with_keys::().unwrap().into_iter().map(|(key, value)| (key, value.sku)).collect() +} + +#[test] +fn the_derive_emits_the_unique_aliases() { + fn aliases() -> Vec<&'static str> { + T::UNIQUE_INDEXES.iter().map(|(alias, _)| *alias).collect() + } + assert_eq!(aliases::(), ["Sku"]); + assert_eq!(aliases::(), ["B", "A"]); + assert!(aliases::().is_empty()); +} + +#[test] +fn a_put_of_a_held_value_at_another_key_is_refused_and_changes_nothing() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("u.cc"); + let mut cache = cache_at(&path); + cache.put("item:b", &unique("S-1")).unwrap(); + let bytes = std::fs::read(&path).unwrap(); + + let text = error_text(cache.put("item:a", &unique("S-1"))); + assert_eq!(text, refusal("item:a", "item:b", "tests.unique_item", "Sku")); + assert!(!text.contains("S-1"), "the refusal never names the value"); + assert_eq!(std::fs::read(&path).unwrap(), bytes, "the store is untouched"); + assert_eq!(cache.get::("item:a").unwrap(), None); + assert_eq!(cache.get_by_index::("Sku", "S-1").unwrap().unwrap().sku, "S-1"); + assert_eq!(cache.get_by_index::("Sku", "S-1").unwrap(), cache.get::("item:b").unwrap()); +} + +// The old bug: write b then a sharing a value used to answer whichever key sorted first. +#[test] +fn the_first_holder_keeps_the_value_whatever_the_key_order() { + let mut cache = memory_cache(); + cache.put("item:z", &unique("S")).unwrap(); + assert!(cache.put("item:a", &unique("S")).is_err()); + assert_eq!(cache.get_by_index::("Sku", "S").unwrap().unwrap(), unique("S")); + assert_eq!(skus(&cache), vec![("item:z".to_string(), "S".to_string())]); +} + +#[test] +fn a_record_rewriting_its_own_key_keeps_its_value_and_a_change_releases_the_old_one() { + let mut cache = memory_cache(); + cache.put("k", &unique("S")).unwrap(); + cache.put("k", &Unique { sku: "S".into(), category: "other".into() }).unwrap(); + assert!(cache.get_by_index::("Sku", "S").unwrap().is_some()); + + cache.put("k", &unique("T")).unwrap(); + assert_eq!(cache.get_by_index::("Sku", "S").unwrap(), None, "a stale value is not found"); + cache.put("j", &unique("S")).unwrap(); + assert_eq!(cache.get_by_index::("Sku", "S").unwrap().unwrap().sku, "S"); + assert_eq!(cache.get_by_index::("Sku", "T").unwrap().unwrap().sku, "T"); + // Updating through `update` is the same rule. + assert!(cache.update::("k", |_| unique("S")).is_err()); + assert_eq!(cache.get::("k").unwrap().unwrap().sku, "T"); +} + +#[test] +fn delete_releases_a_value() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("d.cc"); + let mut cache = cache_at(&path); + cache.put("item:a", &unique("S")).unwrap(); + assert!(cache.put("item:b", &unique("S")).is_err()); + assert!(cache.delete::("item:a").unwrap()); + assert_eq!(cache.get_by_index::("Sku", "S").unwrap(), None); + cache.put("item:b", &unique("S")).unwrap(); + assert!(cache.get_by_index::("Sku", "S").unwrap().is_some()); + assert!(!cache.delete::("item:a").unwrap(), "deleting an absent key releases nothing"); + assert!(cache.get_by_index::("Sku", "S").unwrap().is_some()); + assert!(cache_at(&path).get_by_index::("Sku", "S").unwrap().is_some()); +} + +#[test] +fn types_sharing_an_alias_and_a_value_do_not_conflict() { + let mut cache = memory_cache(); + cache.put("a", &unique("S")).unwrap(); + cache.put("a", &Other { sku: "S".into() }).unwrap(); + cache.put("b", &Other { sku: "T".into() }).unwrap(); + assert!(cache.put("b", &Other { sku: "S".into() }).is_err()); + assert_eq!(cache.get_by_index::("Sku", "S").unwrap().unwrap(), unique("S")); + assert_eq!(cache.get_by_index::("Sku", "S").unwrap().unwrap(), Other { sku: "S".into() }); +} + +#[test] +fn an_alias_the_type_does_not_declare_finds_nothing() { + let mut cache = memory_cache(); + cache.put("a", &unique("S")).unwrap(); + assert_eq!(cache.get_by_index::("sku", "S").unwrap(), None); + assert_eq!(cache.get_by_index::("Category", "c").unwrap(), None); +} + +#[test] +fn blank_and_none_values_never_conflict() { + let mut cache = memory_cache(); + for (key, sku) in [("a", " "), ("b", ""), ("c", "\u{85}"), ("d", "\t\n\u{a0}\u{3000}")] { + cache.put(key, &unique(sku)).unwrap(); + } + cache.put("m1", &multi(None, None)).unwrap(); + cache.put("m2", &multi(None, Some(" "))).unwrap(); + assert_eq!(cache.get_by_index::("Sku", " ").unwrap(), None); + // U+FEFF, U+001F, U+200B, U+180E are values, never trimmed. + cache.put("e", &unique("\u{feff}")).unwrap(); + assert!(cache.put("f", &unique("\u{feff}")).is_err()); + cache.put("g", &unique(" S ")).unwrap(); + cache.put("h", &unique("S")).unwrap(); + assert_eq!(cache.get_by_index::("Sku", " S ").unwrap().unwrap().sku, " S "); + assert_eq!(cache.get_by_index::("Sku", "S").unwrap().unwrap().sku, "S"); +} + +// C# `string.IsNullOrWhiteSpace` over every code unit: exactly these 25 are blank (probed at 7760219b, dotnet 10). +#[test] +fn the_blank_set_is_exactly_the_csharp_white_space() { + let blank: Vec = (0x9..=0xD) + .chain([0x20, 0x85, 0xA0, 0x1680]) + .chain(0x2000..=0x200A) + .chain([0x2028, 0x2029, 0x202F, 0x205F, 0x3000]) + .collect(); + assert_eq!(blank.len(), 25); + let mut cache = memory_cache(); + let mut found = Vec::new(); + for unit in (0..=0xFFFF_u32).filter(|unit| char::from_u32(*unit).is_some()) { + let text = char::from_u32(unit).unwrap().to_string(); + cache.put("a", &unique(&text)).unwrap(); + let second = cache.put("b", &unique(&text)); + if second.is_ok() { + found.push(unit); + cache.delete::("b").unwrap(); + assert_eq!(cache.get_by_index::("Sku", &text).unwrap(), None, "U+{unit:04X} blank is never found"); + } else { + assert_eq!(cache.get_by_index::("Sku", &text).unwrap().unwrap().sku, text); + } + } + assert_eq!(found, blank); +} + +fn staged(cache: &CultCache, rows: &[(&str, T)]) -> Vec { + rows.iter().map(|(key, value)| cache.prepare_entry(*key, value).unwrap().0).collect() +} + +// Two unique indexes, collision on the second-declared, through every write path. +#[test] +fn every_write_path_refuses_and_changes_nothing() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("paths.cc"); + let mut cache = cache_at(&path); + cache.put("m:held", &multi(Some("a1"), Some("b1"))).unwrap(); + let bytes = std::fs::read(&path).unwrap(); + let before = cache.snapshot(); + + let clash_b = multi(Some("a2"), Some("b1")); + let clash_a = multi(Some("a1"), Some("b2")); + for (clash, alias) in [(&clash_b, "B"), (&clash_a, "A")] { + let pick = |text: &str| { + let (first, second) = if "m:new" < "m:held" { ("m:new", "m:held") } else { ("m:held", "m:new") }; + assert_eq!(text, refusal(first, second, "Multi", alias)); + }; + pick(&error_text(cache.put("m:new", clash))); + pick(&error_text(cache.update::("m:new", |_| clash.clone()))); + let envelope = staged(&cache, &[("m:new", clash.clone())]).remove(0); + pick(&error_text(cache.put_envelope::(envelope.clone()))); + pick(&error_text(cache.put_raw_envelope(envelope.clone()))); + pick(&error_text(cache.put_prepared_batch(vec![envelope]))); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + assert_eq!(cache.snapshot(), before); + assert_eq!(cache.get_by_index::("B", "b2").unwrap(), None); + } + // A clean row through every path lands and is found by both aliases. + let fine = multi(Some("a9"), Some("b9")); + cache.put("m:1", &fine).unwrap(); + let envelope = staged(&cache, &[("m:2", multi(Some("a8"), Some("b8")))]).remove(0); + cache.put_envelope::(envelope).unwrap(); + let envelope = staged(&cache, &[("m:3", multi(Some("a7"), Some("b7")))]).remove(0); + cache.put_raw_envelope(envelope).unwrap(); + cache.put_prepared_batch(staged(&cache, &[("m:4", multi(Some("a6"), Some("b6")))])).unwrap(); + for (alias, value, key) in [("A", "a9", 1), ("B", "b8", 2), ("A", "a7", 3), ("B", "b6", 4)] { + let found = cache.get_by_index::(alias, value).unwrap().unwrap(); + assert_eq!(Some(found), cache.get::(&format!("m:{key}")).unwrap()); + } + // Held values survive a reopen of the store: the load installs the same holders. + let mut reopened = cache_at(&path); + assert!(reopened.put("m:x", &multi(None, Some("b9"))).is_err()); + assert!(reopened.put("m:x", &multi(Some("a6"), None)).is_err()); + assert!(reopened.put("m:x", &multi(Some("zz"), Some("yy"))).is_ok()); +} + +#[test] +fn a_batch_is_judged_whole() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("batch.cc"); + let mut cache = cache_at(&path); + cache.put("a", &unique("X")).unwrap(); + cache.put("b", &unique("Y")).unwrap(); + + // A swap inside one batch lands. + cache.put_prepared_batch(staged(&cache, &[("a", unique("Y")), ("b", unique("X"))])).unwrap(); + assert_eq!(cache.get_by_index::("Sku", "Y").unwrap(), cache.get::("a").unwrap()); + assert_eq!(cache.get_by_index::("Sku", "X").unwrap(), cache.get::("b").unwrap()); + assert_eq!(cache_at(&path).get_by_index::("Sku", "X").unwrap().unwrap().sku, "X"); + + // A batch taking a value a record being replaced just released lands; one holding a value twice is refused whole. + cache.put_prepared_batch(staged(&cache, &[("a", unique("Z")), ("c", unique("Y"))])).unwrap(); + let bytes = std::fs::read(&path).unwrap(); + let before = cache.snapshot(); + let text = error_text(cache.put_prepared_batch(staged(&cache, &[("p", unique("N")), ("q", unique("N")), ("r", unique("W"))]))); + assert_eq!(text, refusal("p", "q", "tests.unique_item", "Sku")); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + assert_eq!(cache.snapshot(), before); + + // A key staged twice lands as its last envelope. + cache.put_prepared_batch(staged(&cache, &[("d", unique("D1")), ("d", unique("D2")), ("e", unique("D1"))])).unwrap(); + assert_eq!(cache.get_by_index::("Sku", "D2").unwrap(), cache.get::("d").unwrap()); + assert_eq!(cache.get_by_index::("Sku", "D1").unwrap(), cache.get::("e").unwrap()); +} + +// The verdict and the named pair do not depend on key order, staging order, or alias declaration order. +#[test] +fn the_refusal_names_the_ordinal_pair_whatever_the_order() { + let orders: [[&str; 3]; 6] = + [["a", "b", "c"], ["a", "c", "b"], ["b", "a", "c"], ["b", "c", "a"], ["c", "a", "b"], ["c", "b", "a"]]; + for order in orders { + let mut cache = memory_cache(); + let rows: Vec<_> = order.iter().map(|key| (*key, unique("S"))).collect(); + let text = error_text(cache.put_prepared_batch(staged(&cache, &rows))); + assert_eq!(text, refusal("a", "b", "tests.unique_item", "Sku"), "{order:?}"); + assert!(skus(&cache).is_empty()); + } + // Both aliases collide in one batch: the alias sorting first is named, though `B` is declared first. + for order in [["x", "y"], ["y", "x"]] { + let mut cache = memory_cache(); + let rows: Vec<_> = order.iter().map(|key| (*key, multi(Some("s"), Some("t")))).collect(); + let text = error_text(cache.put_prepared_batch(staged(&cache, &rows))); + assert_eq!(text, refusal("x", "y", "Multi", "A")); + } + // A held key and a landing key: the pair is ordinal, not held-first. + let mut cache = memory_cache(); + cache.put("m", &unique("S")).unwrap(); + assert_eq!(error_text(cache.put("z", &unique("S"))), refusal("m", "z", "tests.unique_item", "Sku")); + assert_eq!(error_text(cache.put("a", &unique("S"))), refusal("a", "m", "tests.unique_item", "Sku")); + // A value that differs only by case or by a surrounding space is another value. + cache.put("n", &unique("s")).unwrap(); + cache.put("o", &unique("S ")).unwrap(); +} + +#[test] +fn a_load_of_a_duplicate_image_is_refused_and_keeps_the_prior_view() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("load.cc"); + let mut cache = cache_at(&path); + cache.put("item:keep", &unique("K")).unwrap(); + + let mut twin = twin_cache_at(&path); + twin.put("item:b", &Twin { sku: "S".into(), category: "c".into() }).unwrap(); + twin.put("item:a", &Twin { sku: "S".into(), category: "c".into() }).unwrap(); + let bytes = std::fs::read(&path).unwrap(); + + let before = cache.snapshot(); + assert_eq!(error_text(cache.pull_all_backing_stores()), refusal("item:a", "item:b", "tests.unique_item", "Sku")); + assert_eq!(cache.snapshot(), before, "a refused pull admits nothing"); + assert_eq!(cache.get_by_index::("Sku", "K").unwrap().unwrap().sku, "K"); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + + // load_soa of the same image through a cache with no store. + let table: CultCacheSoaTable = twin.snapshot_soa(); + let mut soa = memory_cache(); + soa.put("item:keep", &unique("K")).unwrap(); + let before = soa.snapshot(); + assert_eq!(error_text(soa.load_soa(table)), refusal("item:a", "item:b", "tests.unique_item", "Sku")); + assert_eq!(soa.snapshot(), before); + assert!(soa.get_by_index::("Sku", "K").unwrap().is_some()); + + // A clean image loads and replaces the holders with the image's. + let mut clean = twin_cache_at(&dir.path().join("clean.cc")); + clean.put("item:x", &Twin { sku: "X".into(), category: "c".into() }).unwrap(); + soa.load_soa(clean.snapshot_soa()).unwrap(); + assert_eq!(soa.get_by_index::("Sku", "K").unwrap(), None); + assert!(soa.get_by_index::("Sku", "X").unwrap().is_some()); + soa.put("item:y", &unique("K")).unwrap(); +} + +#[test] +fn a_multi_index_store_with_a_duplicate_on_the_second_alias_refuses_to_open() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("multi.cc"); + { + #[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] + #[cultcache(type = "multi", schema = "Multi")] + struct Role { + #[cultcache(key = 0)] + b: Option, + #[cultcache(key = 1)] + a: Option, + } + let mut cache = CultCache::new(); + cache.register_entry_type::().unwrap(); + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap(); + cache.pull_all_backing_stores().unwrap(); + cache.put("m:1", &Role { b: Some("b".into()), a: Some("a1".into()) }).unwrap(); + cache.put("m:2", &Role { b: Some("b".into()), a: Some("a2".into()) }).unwrap(); + } + let mut cache = CultCache::new(); + cache.register_entry_type::().unwrap(); + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap(); + assert_eq!(error_text(cache.pull_all_backing_stores()), refusal("m:1", "m:2", "Multi", "B")); +} + +#[test] +fn a_bool_index_is_found_by_the_csharp_text() { + let mut cache = CultCache::new(); + cache.register_entry_type::().unwrap(); + cache.put("t", &VBool { v: true }).unwrap(); + assert!(cache.put("u", &VBool { v: true }).is_err()); + cache.put("f", &VBool { v: false }).unwrap(); + assert_eq!(cache.get_by_index::("V", "True").unwrap().unwrap().v, true); + assert_eq!(cache.get_by_index::("V", "False").unwrap().unwrap().v, false); + assert_eq!(cache.get_by_index::("V", "true").unwrap(), None); + assert_eq!(cache.get_by_index::("V", "false").unwrap(), None); +} + +// ---- shared vectors, written by the C# reference ---- + +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum Cell { + Nil, + Bool(bool), + Int(i64), + Uint(u64), + Double(f64), + Text(String), +} + +fn vectors() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../tests/vectors/unique-index") +} + +type ValueRow = (String, String, Cell, Option); + +#[test] +fn vectors_values_give_the_csharp_index_text() { + let bytes = std::fs::read(vectors().join("values.msgpack")).unwrap(); + let rows: Vec = rmp_serde::from_slice(&bytes).unwrap(); + assert!(!rows.is_empty()); + let mut checked = 0; + for (label, kind, value, text) in rows { + let mut cache = CultCache::new(); + cache.register_entry_type::().unwrap(); + cache.register_entry_type::().unwrap(); + cache.register_entry_type::().unwrap(); + cache.register_entry_type::().unwrap(); + // `double` rows are skipped: CultLookupValue has no float impl, so Rust cannot index a double member. + match (kind.as_str(), value) { + ("string", Cell::Text(held)) => { + cache.put("one", &VString { v: held.clone() }).unwrap(); + match text { + Some(text) => { + assert_eq!(cache.get_by_index::("V", &text).unwrap().unwrap().v, held, "{label}"); + assert!(cache.put("two", &VString { v: held }).is_err(), "{label}"); + } + None => { + cache.put("two", &VString { v: held.clone() }).unwrap(); + assert_eq!(cache.get_by_index::("V", &held).unwrap(), None, "{label}"); + } + } + } + ("int64", Cell::Int(held)) => { + cache.put("one", &VInt { v: held }).unwrap(); + assert_eq!(cache.get_by_index::("V", &text.unwrap()).unwrap().unwrap().v, held, "{label}"); + assert!(cache.put("two", &VInt { v: held }).is_err(), "{label}"); + } + ("uint64", Cell::Uint(held)) => { + cache.put("one", &VUint { v: held }).unwrap(); + assert_eq!(cache.get_by_index::("V", &text.unwrap()).unwrap().unwrap().v, held, "{label}"); + assert!(cache.put("two", &VUint { v: held }).is_err(), "{label}"); + } + ("uint64", Cell::Int(held)) => { + cache.put("one", &VUint { v: held as u64 }).unwrap(); + assert_eq!(cache.get_by_index::("V", &text.unwrap()).unwrap().unwrap().v, held as u64, "{label}"); + } + ("int64", Cell::Uint(held)) => { + cache.put("one", &VInt { v: held as i64 }).unwrap(); + assert_eq!(cache.get_by_index::("V", &text.unwrap()).unwrap().unwrap().v, held as i64, "{label}"); + } + ("bool", Cell::Bool(held)) => { + cache.put("one", &VBool { v: held }).unwrap(); + assert_eq!(cache.get_by_index::("V", &text.unwrap()).unwrap().unwrap().v, held, "{label}"); + } + ("double", Cell::Double(_) | Cell::Int(_) | Cell::Uint(_) | Cell::Nil) => continue, + (kind, value) => panic!("{label}: unexpected vector row {kind} {value:?}"), + } + checked += 1; + } + assert!(checked > 10, "the vectors were read, not skipped: {checked}"); +} + +type OutcomeRow = (String, Option, String, Option, Option); + +#[test] +fn vectors_stores_hold_their_verdicts() { + let bytes = std::fs::read(vectors().join("outcomes.msgpack")).unwrap(); + let rows: Vec = rmp_serde::from_slice(&bytes).unwrap(); + assert!(rows.len() >= 3); + let dir = tempfile::tempdir().unwrap(); + for (file, alias, verdict, k1, k2) in rows { + let path = dir.path().join(&file); + std::fs::copy(vectors().join(&file), &path).unwrap(); + let before = std::fs::read(&path).unwrap(); + let attach = |cache: &mut CultCache| { + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap(); + cache.pull_all_backing_stores() + }; + let mut cache = CultCache::new(); + let opened = match alias.as_deref() { + Some("Sku") => { + cache.register_entry_type::().unwrap(); + attach(&mut cache) + } + None => { + cache.register_entry_type::().unwrap(); + attach(&mut cache) + } + Some(other) => panic!("unexpected vector alias {other}"), + }; + match verdict.as_str() { + "open" => opened.unwrap_or_else(|error| panic!("{file} / {alias:?} should open: {error:#}")), + "refused" => assert_eq!( + error_text(opened), + refusal(&k1.unwrap(), &k2.unwrap(), "tests.unique_item", "Sku"), + "{file}" + ), + other => panic!("unexpected verdict {other}"), + } + assert_eq!(std::fs::read(&path).unwrap(), before, "{file} is byte-identical"); + } +} + +// A pull replaces the holder table with the stored image's: a value another writer changed is released, not kept. +#[test] +fn a_second_pull_replaces_the_holders_with_the_stored_image() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("pull.cc"); + let mut cache = cache_at(&path); + cache.put("a", &unique("S")).unwrap(); + + cache_at(&path).put("a", &unique("T")).unwrap(); + cache.pull_all_backing_stores().unwrap(); + + assert_eq!(cache.get_by_index::("Sku", "S").unwrap(), None, "the replaced value is no longer held"); + assert_eq!(cache.get_by_index::("Sku", "T").unwrap(), cache.get::("a").unwrap()); + cache.put("b", &unique("S")).unwrap(); + assert!(cache.put("c", &unique("T")).is_err()); +} + +// The projector decides what held records hold: declaring an index for a type that already has records derives +// their holders, or refuses the registration when they collide. +#[test] +fn registering_an_index_over_held_records_derives_their_holders() { + let mut cache = CultCache::new(); + cache.register_entry_type::().unwrap(); + cache.put("a", &Twin { sku: "S".into(), category: "x".into() }).unwrap(); + cache.put("b", &Twin { sku: "T".into(), category: "x".into() }).unwrap(); + + cache.register_entry_type::().unwrap(); + + assert_eq!(cache.get_by_index::("Sku", "S").unwrap(), cache.get::("a").unwrap()); + assert!(cache.get_by_index::("Sku", "S").unwrap().is_some()); + assert!(cache.put("c", &unique("S")).is_err(), "a held value is refused"); + cache.put("c", &unique("U")).unwrap(); +} + +#[test] +fn registering_an_index_over_colliding_held_records_is_refused_and_changes_nothing() { + let mut cache = CultCache::new(); + cache.register_entry_type::().unwrap(); + cache.put("a", &Twin { sku: "S".into(), category: "x".into() }).unwrap(); + cache.put("b", &Twin { sku: "S".into(), category: "y".into() }).unwrap(); + + let text = error_text(cache.register_entry_type::()); + assert_eq!(text, refusal("a", "b", "tests.unique_item", "Sku")); + + // Still the unindexed declaration: duplicates keep landing, nothing is looked up. + cache.put("c", &Twin { sku: "S".into(), category: "z".into() }).unwrap(); + assert_eq!(cache.get_all_with_keys::().unwrap().len(), 3); + assert_eq!(cache.get_by_index::("Sku", "S").unwrap(), None); +} + +/// The same type and schema declaring Sku as an integer: the held text cannot be decoded as one. +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "unique_item", schema = "tests.unique_item")] +struct Retyped { + #[cultcache(key = 0, index = "Sku")] + sku: i64, +} + +/// A refused re-registration leaves the previous declaration in force: it keeps judging, so a later duplicate is refused. +#[test] +fn a_refused_reregistration_keeps_the_previous_index_judging() { + let mut cache = CultCache::new(); + cache.register_entry_type::().unwrap(); + cache.put("a", &unique("S")).unwrap(); + cache.put("b", &unique("T")).unwrap(); + + // A different declaration over the same type (Category is the unique alias) collides on held records. + #[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] + #[cultcache(type = "unique_item", schema = "tests.unique_item")] + struct ByCat { + #[cultcache(key = 0)] + sku: String, + #[cultcache(key = 1, index = "Cat")] + category: String, + } + assert!(cache.register_entry_type::().is_err(), "a and b both hold category c"); + + assert!(cache.put("z", &unique("S")).is_err(), "the previous index still judges"); + assert_eq!(cache.get_by_index::("Sku", "S").unwrap(), cache.get::("a").unwrap()); + cache.put("z", &unique("U")).unwrap(); +} + +// A held record the new declaration cannot decode refuses the registration; the unindexed declaration stays. +#[test] +fn registering_an_index_over_an_undecodable_held_record_is_refused_and_changes_nothing() { + let mut cache = CultCache::new(); + cache.register_entry_type::().unwrap(); + cache.put("a", &Twin { sku: "S".into(), category: "x".into() }).unwrap(); + + assert!(cache.register_entry_type::().is_err()); + + cache.put("b", &Twin { sku: "S".into(), category: "y".into() }).unwrap(); + assert_eq!(cache.get_all_with_keys::().unwrap().len(), 2, "no index judges the duplicate"); +} + +// Dropping the declaration releases the holders. +#[test] +fn registering_without_the_index_releases_the_holders() { + let mut cache = CultCache::new(); + cache.register_entry_type::().unwrap(); + cache.put("a", &unique("S")).unwrap(); + cache.register_entry_type::().unwrap(); + assert_eq!(cache.get_by_index::("Sku", "S").unwrap(), None, "no declaration, no holders"); + cache.put("b", &Twin { sku: "S".into(), category: "x".into() }).unwrap(); + assert!(cache.register_entry_type::().is_err(), "the duplicate now written is a held violation"); +} From 447b310b4d132404f21a0b43181fb37cd3533402 Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 08:05:39 +0000 Subject: [PATCH 13/93] cultcache-py: load order, role-alias register_index refusal, C# two-change commit vector (#96) --- .../cultcache-py/src/cultcache_py/cache.py | 30 +++++++++------- .../cultcache-py/tests/test_unique_index.py | 35 +++++++++++++++++++ .../tests/test_unique_index_watch.py | 10 ++++++ packages/cultnet-py/tests/test_cultnet.py | 27 +++++--------- .../CultNetReplicaCommitTests.cs | 33 +++++++++++++++++ .../vectors/database-change-commit/README.md | 10 ++++++ .../two-change-commit.msgpack | 1 + 7 files changed, 115 insertions(+), 31 deletions(-) create mode 100644 tests/vectors/database-change-commit/README.md create mode 100644 tests/vectors/database-change-commit/two-change-commit.msgpack diff --git a/packages/cultcache-py/src/cultcache_py/cache.py b/packages/cultcache-py/src/cultcache_py/cache.py index c934c33b..badfadb5 100644 --- a/packages/cultcache-py/src/cultcache_py/cache.py +++ b/packages/cultcache-py/src/cultcache_py/cache.py @@ -234,6 +234,8 @@ class CultCache: @_mutating def register_index(self, document: DocumentDefinition[Any], index: str, extractor: str | Any) -> None: self._assert_registered(document) + if index in document.roles: + raise CultCacheError(f"Index '{index}' of {document.type} is declared as a role; a role is never a unique index.") index_extractors = {type: dict(indexes) for type, indexes in self._state.index_extractors.items()} index_extractors.setdefault(document.type, {})[index] = extractor self._install_lookups(self._state.name_extractors, index_extractors) @@ -243,7 +245,11 @@ class CultCache: ) -> None: """Derives every lookup under the candidate extractors, then installs extractors and lookups together. An extractor that raises on a held value installs nothing.""" - names, indexes, lookup_keys = self._derive_lookups(self._state.values, name_extractors, index_extractors) + names, indexes, lookup_keys = self._derive_lookups( + [(type, key, value) for type, values in self._state.values.items() for key, value in values.items()], + name_extractors, + index_extractors, + ) ( self._state.name_extractors, self._state.index_extractors, @@ -311,6 +317,7 @@ class CultCache: structures, running every check and every user extractor; the cache's state is replaced only once that build finishes, so a refused load admits nothing.""" loaded_values: dict[str, dict[str, Any]] = {} + read_order: dict[tuple[str, str], Any] = {} # (type, key) -> value, in the order the stores yielded them loaded_envelopes: dict[str, dict[str, CultCacheEnvelope]] = {} loaded_legacy_global_keys: dict[str, str] = {} routes, generic = self._state.stores_by_type, self._state.generic_store @@ -349,9 +356,10 @@ class CultCache: envelope = self._stamped(document, envelope) value = document.decode_payload(envelope.payload) loaded_values.setdefault(envelope.type, {})[envelope.key] = value + read_order[(envelope.type, envelope.key)] = value loaded_envelopes.setdefault(envelope.type, {})[envelope.key] = envelope loaded_names, loaded_indexes, loaded_lookup_keys = self._derive_lookups( - loaded_values, self._state.name_extractors, self._state.index_extractors + [(type, key, value) for (type, key), value in read_order.items()], self._state.name_extractors, self._state.index_extractors ) # Plain attribute assignments: nothing fallible runs once the swap begins. ( @@ -615,24 +623,22 @@ class CultCache: def _derive_lookups( self, - values_by_type: dict[str, dict[str, Any]], + records: list[tuple[str, str, Any]], name_extractors: dict[str, str | Any], index_extractors: dict[str, dict[str, str | Any]], ) -> tuple[_Names, _Indexes, _LookupKeysByType]: - """Builds name and index lookups for the given values in fresh dicts, touching no cache state.""" + """Builds name and index lookups for the given (type, key, value) records, judged in the order given, in fresh dicts, touching no cache state.""" names: _Names = {} indexes: _Indexes = {} lookup_keys: _LookupKeysByType = {} - for type, values in values_by_type.items(): - name_extractor = name_extractors.get(type) - type_index_extractors = index_extractors.get(type, {}) - for key, value in values.items(): - lookup_keys.setdefault(type, {})[key] = self._lookup_keys_for(value, name_extractor, type_index_extractors) + landing: list[tuple[str, str, _LookupKeys]] = [] + for type, key, value in records: + keys = self._lookup_keys_for(value, name_extractors.get(type), index_extractors.get(type, {})) + lookup_keys.setdefault(type, {})[key] = keys + landing.append((type, key, keys)) # Judged whole, over every type at once and on the file alone (never against what the cache held): a held value can # have been mutated in place since it was admitted, and another process may have moved a value between records. - self._refuse_index_sharing( - [(type, key, keys) for type, by_key in lookup_keys.items() for key, keys in by_key.items()], {}, () - ) + self._refuse_index_sharing(landing, {}, ()) for type in lookup_keys: for key, keys in lookup_keys.get(type, {}).items(): if keys.name is not None: diff --git a/packages/cultcache-py/tests/test_unique_index.py b/packages/cultcache-py/tests/test_unique_index.py index 0dca2aa8..2d988e78 100644 --- a/packages/cultcache-py/tests/test_unique_index.py +++ b/packages/cultcache-py/tests/test_unique_index.py @@ -544,6 +544,41 @@ class BatchAndOrderTests(unittest.TestCase): cache.pull_all_backing_stores() self.assertEqual(str(caught.exception), refusal("s:1", "s:2", "second", "alpha")) + def test_aliases_sort_by_utf16_code_unit_not_code_point(self) -> None: + astral, bmp = "k\U0001F600", "k\uff01" # code points: bmp < astral; UTF-16 units: D83D < FF01 + document = item_doc(indexes={bmp: "sku", astral: "serial"}, roles={}) + cache = cache_of(document) + rows = [Item(sku="S", serial="T"), Item(sku="S", serial="T")] + with self.assertRaises(CultCacheError) as caught: + cache.put_envelopes(document, [envelope(document, f"item:{n}", row) for n, row in enumerate(rows)]) + self.assertEqual(str(caught.exception), refusal("item:0", "item:1", "item", astral)) + + def test_a_load_judges_records_in_the_order_the_file_yields_them(self) -> None: + alpha, beta = item_doc("alpha", indexes={"k": "sku"}, roles={}), item_doc("beta", indexes={"k": "sku"}, roles={}) + loose_alpha, loose_beta = item_doc("alpha", indexes={}, roles={}), item_doc("beta", indexes={}, roles={}) + store = ListStore( + [ + envelope(loose_alpha, "a:0", Item(sku="Q")), + envelope(loose_beta, "b:1", Item(sku="S")), + envelope(loose_beta, "b:2", Item(sku="S")), + envelope(loose_alpha, "a:1", Item(sku="P")), + envelope(loose_alpha, "a:2", Item(sku="P")), + ] + ) + cache = cache_of(alpha, beta, store=store) + with self.assertRaises(CultCacheError) as caught: + cache.pull_all_backing_stores() + self.assertEqual(str(caught.exception), refusal("b:1", "b:2", "beta", "k")) + + def test_register_index_refuses_an_alias_the_definition_declares_as_a_role(self) -> None: + document = item_doc() + cache = cache_of(document) + with self.assertRaises(CultCacheError): + cache.register_index(document, "group", "group") + cache.put_envelopes( + document, [envelope(document, "item:1", Item(group="guide")), envelope(document, "item:2", Item(group="guide"))] + ) + class LoadFreshnessTests(unittest.TestCase): def test_a_load_is_judged_on_the_file_alone_not_against_the_held_map(self) -> None: diff --git a/packages/cultmesh-py/tests/test_unique_index_watch.py b/packages/cultmesh-py/tests/test_unique_index_watch.py index 44c606ff..ccc0b44d 100644 --- a/packages/cultmesh-py/tests/test_unique_index_watch.py +++ b/packages/cultmesh-py/tests/test_unique_index_watch.py @@ -36,6 +36,16 @@ class WatchByIndexTests(unittest.TestCase): self.assertEqual(misheard, []) self.assertIsNone(node.database.get_by_index(document, "price", str_text)) + def test_a_watch_hears_a_record_leaving_a_value_by_the_canonical_text(self) -> None: + document = define_database_entry_type("mesh.leaving", [("price", 0)], indexes={"price": "price"}) + node = CultMesh.create_node(runtime_id="mesh-watch-leaving") + node.database.register_document(document) + node.database.put(document, "item:1", {"price": 2.0}) + heard: list[str] = [] + node.database.watch_by_index(document, "price", "2", lambda change: heard.append(change.record_key)) + node.database.put(document, "item:1", {"price": 3.0}) + self.assertEqual(heard, ["item:1"]) + def test_a_blank_value_is_never_indexed_so_never_watched(self) -> None: document = define_database_entry_type("mesh.blank", [("sku", 0)], indexes={"sku": "sku"}) node = CultMesh.create_node(runtime_id="mesh-watch-blank") diff --git a/packages/cultnet-py/tests/test_cultnet.py b/packages/cultnet-py/tests/test_cultnet.py index 5ece34be..0c8bcbf3 100644 --- a/packages/cultnet-py/tests/test_cultnet.py +++ b/packages/cultnet-py/tests/test_cultnet.py @@ -13,6 +13,7 @@ from datetime import UTC, datetime from pathlib import Path from typing import Callable from uuid import uuid4 +import msgpack from cultcache_py import CultCache, define_database_entry_type from cultnet_py.benchmark import run_benchmark from cultnet_py.compare_csharp import ( @@ -1600,25 +1601,13 @@ class CultNetTests(unittest.TestCase): with self.subTest(field=field, bad=bad), self.assertRaisesRegex(ValueError, field): CultNetDatabaseChange.from_wire({**base, field: bad}) - def test_a_two_change_commit_from_the_csharp_server_parses(self) -> None: - # CultNetDatabaseServer.DeliverCommit (CultNetDatabaseServer.cs:791-797) tags every change of a commit with its - # commitId and commitCount = the number of changes delivered, so a swap of two records arrives with commitCount 2. - # Shaped as the C# [MessagePackObject] writes it: every key present, nil where unset. - swap = [ - { - "schemaVersion": "cultnet.database_change_raw.v0", - "messageId": f"change-{index}", - "subscriptionId": "sub-1", - "changeKind": "updated", - "document": None, - "recordKey": key, - "schemaId": "schema-note", - "commitId": "9f1c0f0e4b7a4c1f9d2e", - "commitCount": 2, - } - for index, key in enumerate(("note:a", "note:b")) - ] - changes = [CultNetDatabaseChange.from_wire(message) for message in swap] + def test_a_two_change_commit_written_by_csharp_parses(self) -> None: + # tests/vectors/database-change-commit/two-change-commit.msgpack: the two change messages of one commit, written by the + # C# serializer (CultNetReplicaCommitTests), so a rename or retyping of commitId/commitCount fails here. + vector = Path(__file__).resolve().parents[3] / "tests" / "vectors" / "database-change-commit" / "two-change-commit.msgpack" + messages = list(msgpack.Unpacker(io.BytesIO(vector.read_bytes()), raw=False)) + self.assertEqual(len(messages), 2) + changes = [CultNetDatabaseChange.from_wire(message) for message in messages] self.assertEqual([change.commit_count for change in changes], [2, 2]) self.assertEqual({change.commit_id for change in changes}, {"9f1c0f0e4b7a4c1f9d2e"}) self.assertEqual([change.to_wire()["commitCount"] for change in changes], [2, 2]) diff --git a/tests/GameCult.Networking.Tests/CultNetReplicaCommitTests.cs b/tests/GameCult.Networking.Tests/CultNetReplicaCommitTests.cs index 6059d7ba..b38592ec 100644 --- a/tests/GameCult.Networking.Tests/CultNetReplicaCommitTests.cs +++ b/tests/GameCult.Networking.Tests/CultNetReplicaCommitTests.cs @@ -225,6 +225,39 @@ namespace GameCult.Networking.Tests Assert.That(run.Faults, Has.Count.EqualTo(1)); } + [Test] + public void ATwoChangeCommitIsWrittenAsTheVectorOtherRuntimesDecode() + { + // tests/vectors/database-change-commit/two-change-commit.msgpack: the two change messages of one commit, as the C# + // serializer writes them, concatenated. Written with CULTCACHE_WRITE_VECTORS=1; every other run compares bytes. + var commit = Enumerable.Range(0, 2).Select(index => new CultNetDatabaseChangeRawMessage + { + MessageId = $"change-{index}", + SubscriptionId = "sub-1", + ChangeKind = "removed", + RecordKey = index == 0 ? "note:a" : "note:b", + SchemaId = "schema-note", + CommitId = "9f1c0f0e4b7a4c1f9d2e", + CommitCount = 2 + }); + var bytes = commit.SelectMany(message => CultNetSchemaMessageSerialization.Serialize(message)).ToArray(); + + var directory = TestContext.CurrentContext.TestDirectory; + while (directory != null && !System.IO.Directory.Exists(System.IO.Path.Combine(directory, "tests", "vectors"))) + directory = System.IO.Path.GetDirectoryName(directory); + Assert.That(directory, Is.Not.Null, "tests/vectors is above the test directory"); + var path = System.IO.Path.Combine(directory!, "tests", "vectors", "database-change-commit", "two-change-commit.msgpack"); + if (Environment.GetEnvironmentVariable("CULTCACHE_WRITE_VECTORS") == "1") + { + System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(path)!); + System.IO.File.WriteAllBytes(path, bytes); + return; + } + + Assert.That(System.IO.File.Exists(path), Is.True, "the vector is committed"); + Assert.That(Convert.ToHexString(bytes), Is.EqualTo(Convert.ToHexString(System.IO.File.ReadAllBytes(path)))); + } + [Test] public void AnOlderPeerReadsTheChangeAndIgnoresTheCommitFields() { diff --git a/tests/vectors/database-change-commit/README.md b/tests/vectors/database-change-commit/README.md new file mode 100644 index 00000000..39a78fa1 --- /dev/null +++ b/tests/vectors/database-change-commit/README.md @@ -0,0 +1,10 @@ +# database-change-commit vectors + +Written only by the C# reference, `ATwoChangeCommitIsWrittenAsTheVectorOtherRuntimesDecode` in +`tests/GameCult.Networking.Tests/CultNetReplicaCommitTests.cs`, with `CULTCACHE_WRITE_VECTORS=1`. Every other run +compares the bytes. + +- `two-change-commit.msgpack`: two `cultnet.database_change_raw.v0` messages (`removed`, `note:a` and `note:b`) + concatenated, each as the C# `[MessagePackObject]` writes it (every key present, nil where unset), both carrying + `commitId` `9f1c0f0e4b7a4c1f9d2e` and `commitCount` 2. Read it as a msgpack stream. Python walks it in + `packages/cultnet-py/tests/test_cultnet.py`. diff --git a/tests/vectors/database-change-commit/two-change-commit.msgpack b/tests/vectors/database-change-commit/two-change-commit.msgpack new file mode 100644 index 00000000..055dc4ae --- /dev/null +++ b/tests/vectors/database-change-commit/two-change-commit.msgpack @@ -0,0 +1 @@ +‰­schemaVersion¾cultnet.database_change_raw.v0©messageId¨change-0®subscriptionId¥sub-1ªchangeKind§removed¨documentÀ©recordKey¦note:a¨schemaId«schema-note¨commitId´9f1c0f0e4b7a4c1f9d2e«commitCount‰­schemaVersion¾cultnet.database_change_raw.v0©messageId¨change-1®subscriptionId¥sub-1ªchangeKind§removed¨documentÀ©recordKey¦note:b¨schemaId«schema-note¨commitId´9f1c0f0e4b7a4c1f9d2e«commitCount \ No newline at end of file From 5e610b1b6e40c8277d5fe25eab45368a39457069 Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 08:12:07 +0000 Subject: [PATCH 14/93] CI: run cultcache-py, cultcache-rs and GameCult.Caching.Tests on push and PR (#87) --- .github/workflows/cultmesh-portability.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/cultmesh-portability.yml b/.github/workflows/cultmesh-portability.yml index 7ef2d543..1a838e94 100644 --- a/.github/workflows/cultmesh-portability.yml +++ b/.github/workflows/cultmesh-portability.yml @@ -115,6 +115,26 @@ jobs: - name: Verify the CultCache document owner with the TypeScript cache run: npm run test --workspace packages/cultcache-ts + # The Python cache's unit suite ran only in cultnet-interop.yml (windows-latest, dispatch-only), so on Linux + # it ran nowhere. It sits after the TypeScript cache's test so the built cultcache-ts store holder exists for the + # cross-runtime store-lock test, which skips without it. + - name: Verify the CultCache document owner with the Python cache + run: python -m unittest discover -s packages/cultcache-py/tests + + # cargo test for packages/cultcache-rs ran in no workflow: the Rust cache's own unit and doc tests were unwatched + # (only the interop example built, inside the C# store tests). The target directory is under RUNNER_TEMP, away from the checkout. + - name: Verify the CultCache document owner with the Rust cache + shell: bash + run: CARGO_TARGET_DIR="$RUNNER_TEMP/cultcache-rs-target" cargo test --manifest-path packages/cultcache-rs/Cargo.toml + + # tests/GameCult.Caching.Tests, the C# reference cache's own suite (about 1,260 tests, under a minute), ran in no + # forge workflow; only the Mesh tests above exercised the reference. + # The forge runner is globalization-invariant, so ADeterministicIdIsTheSameUnderEveryCulture... cannot construct a + # culture and throws CultureNotFoundException; it is excluded here and still runs wherever ICU is present + # (variants:follow_up:culture-test-invariant-image). + - name: Verify the CultCache document owner with the C# reference + run: dotnet test tests/GameCult.Caching.Tests/GameCult.Caching.Tests.csproj --filter "FullyQualifiedName!~ADeterministicIdIsTheSameUnderEveryCulture" --logger "console;verbosity=minimal" + # Packs the four TypeScript packages and installs them into a clean consumer, so a package that builds but does # not install or load fails here. - name: Verify the packed TypeScript packages install and load from a clean consumer From a6861adadb6c47573d4599ad7a69d3cb0bb78022 Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 08:43:52 +0000 Subject: [PATCH 15/93] Unique-index leftovers: TS registerIndex refuses a role, commit vector in TS/Rust, nil document in the change schema (#97) --- .../cultnet/cultnet-distributed-database.md | 3 ++ .../cultnet.database-change-raw.schema.json | 26 ++++++++--- packages/cultcache-ts/src/cult-cache.ts | 5 +++ packages/cultcache-ts/src/document.ts | 6 +++ .../cultcache-ts/test/unique-index.test.ts | 14 +++++- .../src/cultnet_py/schema_catalog.py | 6 +-- packages/cultnet-py/tests/test_cultnet.py | 7 +-- .../tests/database_change_commit_vector.rs | 43 +++++++++++++++++++ .../database-change-commit-vector.test.ts | 39 +++++++++++++++++ .../CultNetSchemaContractTests.cs | 35 +++++++++++++++ 10 files changed, 169 insertions(+), 15 deletions(-) create mode 100644 packages/cultnet-rs/tests/database_change_commit_vector.rs create mode 100644 packages/cultnet-ts/test/database-change-commit-vector.test.ts diff --git a/contracts/cultnet/cultnet-distributed-database.md b/contracts/cultnet/cultnet-distributed-database.md index 1ddc80e8..8d35fbb6 100644 --- a/contracts/cultnet/cultnet-distributed-database.md +++ b/contracts/cultnet/cultnet-distributed-database.md @@ -146,6 +146,9 @@ A malformed put or delete is `message_invalid`, not a per-operation code: the pe commit's changes as one commit, so the replica judges the result (a swap of two unique-index values) and not the state between records. A replica whose cache refuses a commit is faulted, never silent. `commitId` is opaque and valid only within the subscription. The C# subscription server and database server tag commits; other runtimes adopt next. +- The C# writer sets every key of a `database_change_raw` message, nil where unset: a removed change carries `document` + nil, an added or updated one `recordKey` and `schemaId` nil. The schema accepts those nils; an added or updated change + still needs a document, and a removed one a `recordKey` and `schemaId`. - Client-owned input documents may be predicted locally only inside explicit `CultNetClientAuthorityScope` declarations. - Predicted input state is not authoritative shared state. The shard log still diff --git a/contracts/cultnet/cultnet.database-change-raw.schema.json b/contracts/cultnet/cultnet.database-change-raw.schema.json index f1cd97eb..badada4a 100644 --- a/contracts/cultnet/cultnet.database-change-raw.schema.json +++ b/contracts/cultnet/cultnet.database-change-raw.schema.json @@ -10,17 +10,29 @@ "messageId": { "type": "string", "minLength": 1 }, "subscriptionId": { "type": "string", "minLength": 1 }, "changeKind": { "enum": ["added", "updated", "removed"] }, - "document": { "$ref": "./cultnet.raw-document-record.schema.json" }, - "recordKey": { "type": "string", "minLength": 1 }, - "schemaId": { "type": "string", "minLength": 1 }, + "document": { + "oneOf": [{ "type": "null" }, { "$ref": "./cultnet.raw-document-record.schema.json" }] + }, + "recordKey": { "type": ["string", "null"], "minLength": 1 }, + "schemaId": { "type": ["string", "null"], "minLength": 1 }, "commitId": { "type": ["string", "null"], "minLength": 1 }, "commitCount": { "type": ["integer", "null"], "minimum": 2 } }, - "allOf": [ + "oneOf": [ { - "if": { "properties": { "changeKind": { "const": "removed" } } }, - "then": { "required": ["recordKey", "schemaId"] }, - "else": { "required": ["document"] } + "properties": { + "changeKind": { "const": "removed" }, + "recordKey": { "type": "string" }, + "schemaId": { "type": "string" } + }, + "required": ["recordKey", "schemaId"] + }, + { + "properties": { + "changeKind": { "enum": ["added", "updated"] }, + "document": { "$ref": "./cultnet.raw-document-record.schema.json" } + }, + "required": ["document"] } ] } diff --git a/packages/cultcache-ts/src/cult-cache.ts b/packages/cultcache-ts/src/cult-cache.ts index 775118d4..fde76001 100644 --- a/packages/cultcache-ts/src/cult-cache.ts +++ b/packages/cultcache-ts/src/cult-cache.ts @@ -1,4 +1,5 @@ import { + declaresRole, decodeDocumentPayload, documentPayloadBytes, documentPayloadFromBytes, @@ -240,6 +241,10 @@ export class CultCache { throw new Error(`CultCache index names for type "${definition.type}" must be non-empty.`); } + if (declaresRole(definition, indexName)) { + throw new Error(`Index "${indexName}" of ${definition.type} is declared as a role; a role is never a unique index.`); + } + const registered = this.#requireDefinition(definition); const indexAccessors = new Map(registered.indexAccessors); indexAccessors.set(indexName, this.#compileAccessor(definition.type, `index "${indexName}"`, accessor)); diff --git a/packages/cultcache-ts/src/document.ts b/packages/cultcache-ts/src/document.ts index bc4ace88..10af96a6 100644 --- a/packages/cultcache-ts/src/document.ts +++ b/packages/cultcache-ts/src/document.ts @@ -131,6 +131,12 @@ export function encodeDocumentPayload(definition: AnyCultCacheDocumentDefinition return documentPayloadFromBytes(bytes); } +// Whether the definition declares `name` as a role alias (either `roles` form). +export function declaresRole(definition: AnyCultCacheDocumentDefinition, name: string): boolean { + const roles = definition.roles; + return Array.isArray(roles) ? roles.some((entry) => entry.name === name) : roles ? Object.hasOwn(roles, name) : false; +} + // The schema members of a document: the ones it declares, else those its codec derives, else none. export function resolveDocumentMembers(definition: AnyCultCacheDocumentDefinition): readonly CultCacheSchemaCatalogMember[] { if (definition.members || !definition.codec) { diff --git a/packages/cultcache-ts/test/unique-index.test.ts b/packages/cultcache-ts/test/unique-index.test.ts index 67af97fb..852ea08b 100644 --- a/packages/cultcache-ts/test/unique-index.test.ts +++ b/packages/cultcache-ts/test/unique-index.test.ts @@ -381,11 +381,21 @@ test("a refused load leaves the cache's prior state as it was, and a later good } }); -test("registerIndex over held duplicates installs nothing", async () => { +test("registerIndex refuses a name the definition declares as a role", async () => { const document = item("item", "roles"); const cache = memory(document); await cache.put(document, "a", { sku: "S", group: "g" }); await cache.put(document, "b", { sku: "S", group: "h" }); + await assert.rejects(cache.registerIndex(document, "sku", "sku"), { message: /declared as a role; a role is never a unique index/u }); + assert.equal(cache.getKeyByIndex(document, "sku", "S"), undefined); + await cache.put(document, "c", { sku: "S", group: "i" }); +}); + +test("registerIndex over held duplicates installs nothing", async () => { + const document = item("item", "none"); + const cache = memory(document); + await cache.put(document, "a", { sku: "S", group: "g" }); + await cache.put(document, "b", { sku: "S", group: "h" }); await assert.rejects(cache.registerIndex(document, "sku", "sku"), { message: refusal("a", "b", "tests.item", "sku") }); assert.equal(cache.getKeyByIndex(document, "sku", "S"), undefined, "no map was installed"); await cache.put(document, "c", { sku: "S", group: "i" }); @@ -468,7 +478,7 @@ test("a load judges every unique alias: a store collision on the second alias re }); test("registerIndex of a second unique alias judges it, naming the two smallest keys whatever order they were written in", async () => { - const document = pair({ indexes: { a: "a" }, roles: { b: "b" } }); + const document = pair({ indexes: { a: "a" } }); const cache = memory(document); await cache.put(document, "z", { a: "1", b: "s" }); await cache.put(document, "m", { a: "2", b: "s" }); diff --git a/packages/cultnet-py/src/cultnet_py/schema_catalog.py b/packages/cultnet-py/src/cultnet_py/schema_catalog.py index 85ab799d..b640b753 100644 --- a/packages/cultnet-py/src/cultnet_py/schema_catalog.py +++ b/packages/cultnet-py/src/cultnet_py/schema_catalog.py @@ -429,9 +429,9 @@ def wire_message_schema_properties(schema_version: str) -> dict[str, Any]: "messageId": common["messageId"], "subscriptionId": common["subscriptionId"], "changeKind": {"enum": ["added", "updated", "removed"]}, - "document": {"$ref": "#/$defs/rawDocumentRecord"}, - "schemaId": common["schemaId"], - "recordKey": common["recordKey"], + "document": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/rawDocumentRecord"}]}, + "schemaId": {"type": ["string", "null"], "minLength": 1}, + "recordKey": {"type": ["string", "null"], "minLength": 1}, "commitId": {"type": ["string", "null"], "minLength": 1}, "commitCount": {"type": ["integer", "null"], "minimum": 2}, }, diff --git a/packages/cultnet-py/tests/test_cultnet.py b/packages/cultnet-py/tests/test_cultnet.py index 0c8bcbf3..68f8f8ea 100644 --- a/packages/cultnet-py/tests/test_cultnet.py +++ b/packages/cultnet-py/tests/test_cultnet.py @@ -2315,9 +2315,10 @@ class CultNetTests(unittest.TestCase): change_schema = json.loads(by_version["cultnet.database_change_raw.v0"]["schemaJson"]) self.assertEqual(change_schema["properties"]["changeKind"]["enum"], ["added", "updated", "removed"]) - self.assertIn("document", change_schema["properties"]) - self.assertIn("schemaId", change_schema["properties"]) - self.assertIn("recordKey", change_schema["properties"]) + # The C# writer sets every key, nil where unset (a removed change's document, an added change's recordKey and schemaId). + self.assertEqual(change_schema["properties"]["document"]["oneOf"][0], {"type": "null"}) + self.assertEqual(change_schema["properties"]["schemaId"], {"type": ["string", "null"], "minLength": 1}) + self.assertEqual(change_schema["properties"]["recordKey"], {"type": ["string", "null"], "minLength": 1}) self.assertEqual(change_schema["properties"]["commitId"], {"type": ["string", "null"], "minLength": 1}) self.assertEqual(change_schema["properties"]["commitCount"], {"type": ["integer", "null"], "minimum": 2}) diff --git a/packages/cultnet-rs/tests/database_change_commit_vector.rs b/packages/cultnet-rs/tests/database_change_commit_vector.rs new file mode 100644 index 00000000..322227dc --- /dev/null +++ b/packages/cultnet-rs/tests/database_change_commit_vector.rs @@ -0,0 +1,43 @@ +//! `tests/vectors/database-change-commit/two-change-commit.msgpack` is written only by the C# reference: two +//! `cultnet.database_change_raw.v0` messages of one commit, every key present, nil where unset. cultnet-rs has no typed +//! `database_change_raw` message yet, so this walks the stream with the same `rmpv` values its wire decoder builds on and +//! pins the keys and values a Rust adopter will read. A rename of `commitId` or `commitCount` in the C# writer fails here. + +use rmpv::Value; +use std::io::Cursor; + +fn messages() -> Vec { + let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../tests/vectors/database-change-commit/two-change-commit.msgpack"); + let bytes = std::fs::read(path).expect("the vector is committed"); + let mut cursor = Cursor::new(bytes.as_slice()); + let mut out = Vec::new(); + while (cursor.position() as usize) < bytes.len() { + out.push(rmpv::decode::read_value(&mut cursor).expect("a msgpack value")); + } + out +} + +fn field<'a>(message: &'a Value, key: &str) -> &'a Value { + message + .as_map() + .and_then(|entries| entries.iter().find(|(k, _)| k.as_str() == Some(key))) + .map(|(_, v)| v) + .unwrap_or_else(|| panic!("the C# writer wrote no key {key}")) +} + +#[test] +fn a_two_change_commit_written_by_csharp_carries_its_commit_id_count_and_changes() { + let messages = messages(); + assert_eq!(messages.len(), 2); + for message in &messages { + assert_eq!(field(message, "schemaVersion").as_str(), Some("cultnet.database_change_raw.v0")); + assert_eq!(field(message, "commitId").as_str(), Some("9f1c0f0e4b7a4c1f9d2e")); + assert_eq!(field(message, "commitCount").as_u64(), Some(2)); + assert_eq!(field(message, "changeKind").as_str(), Some("removed")); + assert_eq!(field(message, "subscriptionId").as_str(), Some("sub-1")); + assert_eq!(field(message, "schemaId").as_str(), Some("schema-note")); + assert!(field(message, "document").is_nil(), "a removed change's document is nil"); + } + let keys: Vec<_> = messages.iter().map(|m| field(m, "recordKey").as_str().unwrap().to_string()).collect(); + assert_eq!(keys, ["note:a", "note:b"]); +} diff --git a/packages/cultnet-ts/test/database-change-commit-vector.test.ts b/packages/cultnet-ts/test/database-change-commit-vector.test.ts new file mode 100644 index 00000000..fea121c0 --- /dev/null +++ b/packages/cultnet-ts/test/database-change-commit-vector.test.ts @@ -0,0 +1,39 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import Ajv2020 from "ajv/dist/2020.js"; +import { decodeMulti } from "@msgpack/msgpack"; + +import { cultNetSchemas, parseCultNetMessage } from "../src/index"; + +// tests/vectors/database-change-commit/two-change-commit.msgpack is written only by the C# reference: two removed +// database_change_raw messages of one commit, every key present, nil where unset (document included). +const vector = join(__dirname, "..", "..", "..", "..", "tests", "vectors", "database-change-commit", "two-change-commit.msgpack"); +const changes = () => [...decodeMulti(readFileSync(vector))] as Record[]; + +test("a two-change commit written by C# decodes with its commitId, commitCount and changes", () => { + const messages = changes().map((message) => parseCultNetMessage(structuredClone(message))); + assert.equal(messages.length, 2); + for (const message of messages) { + assert.equal(message.schemaVersion, "cultnet.database_change_raw.v0"); + assert.equal((message as { commitId?: unknown }).commitId, "9f1c0f0e4b7a4c1f9d2e"); + assert.equal((message as { commitCount?: unknown }).commitCount, 2); + assert.equal((message as { changeKind?: unknown }).changeKind, "removed"); + assert.equal((message as { subscriptionId?: unknown }).subscriptionId, "sub-1"); + assert.equal((message as { schemaId?: unknown }).schemaId, "schema-note"); + } + assert.deepEqual(messages.map((message) => (message as { recordKey?: unknown }).recordKey), ["note:a", "note:b"]); +}); + +test("the database_change_raw schema accepts the C# vector's removed changes (document: nil) and still refuses an added change without a document", () => { + const ajv = new Ajv2020({ allErrors: true, strict: false }); + ajv.addSchema(cultNetSchemas.rawDocumentRecordSchema); + const validate = ajv.compile(cultNetSchemas.databaseChangeRawSchema); + for (const change of changes()) { + assert.equal(change.document, null, "the C# writer wrote the key, nil"); + assert.equal(validate(change), true, JSON.stringify(validate.errors)); + assert.equal(validate({ ...change, changeKind: "added" }), false, "an added change needs a document"); + } +}); diff --git a/tests/GameCult.Networking.Tests/CultNetSchemaContractTests.cs b/tests/GameCult.Networking.Tests/CultNetSchemaContractTests.cs index 4172c83b..ec9dde5b 100644 --- a/tests/GameCult.Networking.Tests/CultNetSchemaContractTests.cs +++ b/tests/GameCult.Networking.Tests/CultNetSchemaContractTests.cs @@ -1,5 +1,7 @@ #nullable enable using System; +using System.Buffers; +using System.Collections.Generic; using System.IO; using System.Text.Json; using System.Threading.Tasks; @@ -246,6 +248,39 @@ namespace GameCult.Networking.Tests Assert.That(Convert.ToHexString(CultNetSchemaMessageSerialization.Serialize(message)), Is.EqualTo("85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D93E6D6573736167655F696E76616C69643A2052617720707574206D65737361676573206D75737420636F6E7461696E206120646F63756D656E74206D61702EAB726F7574696E6748696E74C0A4636F6465AF6D6573736167655F696E76616C6964A764657461696C7384A56669656C64A8646F63756D656E74A576616C7565C0A461734F66C0A763757272656E74C0")); } + // The C# writer sets every key (nil where unset), so a removed change carries document: nil, and an added change + // carries recordKey/schemaId: nil. The schema describes those bytes; it still refuses an added change with no document. + private static List CommitVectorChanges() + { + var path = Path.Combine(RepoRoot(), "tests", "vectors", "database-change-commit", "two-change-commit.msgpack"); + var reader = new MessagePackReader(File.ReadAllBytes(path)); + var changes = new List(); + while (!reader.End) + changes.Add(JsonDocument.Parse(MessagePackSerializer.ConvertToJson(reader.ReadRaw().ToArray())).RootElement); + return changes; + } + + [Test] + public void DatabaseChangeRaw_TheCommitVectorsRemovedChangesWithANilDocument_MatchTheSchema() + { + var validator = new MiniJsonSchemaValidator(SchemaDir()); + var changes = CommitVectorChanges(); + Assert.That(changes, Has.Count.EqualTo(2)); + foreach (var change in changes) + { + Assert.That(change.GetProperty("document").ValueKind, Is.EqualTo(JsonValueKind.Null), "the C# writer wrote the key, nil"); + validator.AssertValid(change, "cultnet.database-change-raw.schema.json"); + } + } + + [Test] + public void DatabaseChangeRaw_AnAddedChangeWithANilDocument_IsRefused() + { + var validator = new MiniJsonSchemaValidator(SchemaDir()); + var removed = CommitVectorChanges()[0].GetRawText().Replace("\"removed\"", "\"added\""); + Assert.Throws(() => validator.AssertValid(JsonDocument.Parse(removed).RootElement, "cultnet.database-change-raw.schema.json")); + } + private static void AssertErrorMatchesSchema(CultNetErrorMessage message) { var validator = new MiniJsonSchemaValidator(SchemaDir()); From 7002c252ed12bdc199858ef45425ed8451e1bd6e Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 08:53:13 +0000 Subject: [PATCH 16/93] Element ids: the existing-member route and one-way v3/v5 rollback, documented (#98) --- docs/document-variants-id-sweep.md | 2 + docs/nuget-packaging.md | 8 +++ docs/runtime-parity-scope.md | 2 +- packages/cultcache-py/CHANGELOG.md | 2 +- packages/cultcache-py/README.md | 4 ++ packages/cultcache-rs/CHANGELOG.md | 2 +- packages/cultcache-rs/README.md | 4 ++ packages/cultcache-ts/CHANGELOG.md | 3 +- packages/cultcache-ts/README.md | 4 ++ src/GameCult.Caching.MessagePack/README.md | 4 ++ src/GameCult.Caching/CultElementIds.cs | 2 +- src/GameCult.Caching/README.md | 54 +++++++++++++++++++ .../ElementIdEngineTests.cs | 2 +- .../GameCult.Caching.Tests/ElementIdTests.cs | 52 ++++++++++++++++++ unity/org.gamecult.cultlib/CHANGELOG.md | 2 +- 15 files changed, 140 insertions(+), 7 deletions(-) diff --git a/docs/document-variants-id-sweep.md b/docs/document-variants-id-sweep.md index 03fad9cb..74ecd023 100644 --- a/docs/document-variants-id-sweep.md +++ b/docs/document-variants-id-sweep.md @@ -4,6 +4,8 @@ Data for the per-consumer sweep in docs/document-variants-cut.md, C2. Every obje Method: a static scan of `F:\Projects\` C# files for types marked `[CultDocument(`, walked through keyed members, base classes, dictionary values and `[Union]` subtypes. Read-only; no consumer was edited. Worktree copies (`Aetheria-catalog`, `CultLib-*`, and so on) are excluded. Struct elements are values and need no id. Non-C# consumers carry the id as an ordinary member and are not listed. The scan is text-based: a document registered without a literal `[CultDocument(` attribute, or a type whose members lack an integer `[Key]`, is missed, so the sweep pass should confirm each repo by registering its types once. +A type that already has a readable string id keeps it and adds a trailing keyed string member marked `[CultElementId(nameof(ExistingId))]`; see the Element ids section of `src/GameCult.Caching/README.md`. + Lines: `Type file:line (via Owner.Member)`, paths relative to the consumer root. diff --git a/docs/nuget-packaging.md b/docs/nuget-packaging.md index 301e54eb..841deedf 100644 --- a/docs/nuget-packaging.md +++ b/docs/nuget-packaging.md @@ -27,3 +27,11 @@ Install the Unity package from the immutable release tag: ```text https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v2.0.0 ``` + +## Rolling back across the store format + +A consumer that upgrades and writes a store holding an element id leaves a +`cultcache.store.v3` single-file store (`v5` directory store). Refusal by name depends on the store kind and the pin. A **directory** store (`v5`) is refused by every Unity pin from `cultlib-unity-v1.0.57` and `caching-unity-v1.2.0` on (the earliest tags of each line whose `DirectoryMessagePackBackingStore` reads only the `v4` stamp: "only ... is readable"). A **single-file** store (`v3`) is refused only from `cultlib-unity-v2.0.0` and `caching-unity-v2.0.0`: `cultlib-unity-v1.0.61` and `caching-unity-v1.4.0` read the header string and never compare it. The NuGet line (`cultlib-v1.0.32`, the last `cultlib-v` tag) refuses neither: its directory store writes `cultcache.store.v1.directory` and never checks the stamp on read. Rolling back a single-file store to any of those pins reads it silently, drops the ids, and can rewrite the elements without them. That is the dangerous case. There is +no general carry-back; restore from a copy taken before upgrading, and upgrade +readers before writers. Detail: the Store format and rollback section of +`src/GameCult.Caching/README.md`. diff --git a/docs/runtime-parity-scope.md b/docs/runtime-parity-scope.md index e001bb1d..2f54e2fe 100644 --- a/docs/runtime-parity-scope.md +++ b/docs/runtime-parity-scope.md @@ -16,7 +16,7 @@ Cross-runtime parity has three layers: Every store reader refuses, by name, a header or record slot it does not understand (vectors: `tests/vectors/document-variants-c0`). Element ids: a store that can hold them declares `cultcache.store.v3`; TypeScript, - Python and Rust read it and keep the header, and readers older than ids refuse it + Python and Rust read it and keep the header, and the C# runtime decides the mark from content, and these runtimes never drop it. A C# reader from `cultlib-unity-v2.0.0` on refuses a v3 single-file store it cannot read (earlier pins do not) (vectors: `tests/vectors/document-variants-c2a`). Document variants: C# resolves them (`cultcache.store.v2`); every other runtime, CultMesh single-file reads, the directory store and CultNet refuse a variant by name diff --git a/packages/cultcache-py/CHANGELOG.md b/packages/cultcache-py/CHANGELOG.md index 641cc0ed..d440ddca 100644 --- a/packages/cultcache-py/CHANGELOG.md +++ b/packages/cultcache-py/CHANGELOG.md @@ -11,7 +11,7 @@ - `indexes` are unique, as C# `[CultIndex]`: a write, load or `register_index` that leaves two records of a type holding one non-blank value under one alias is refused (`CultCacheError`) naming both keys and the alias, and writes nothing. Index text is the C# canonical text (`True`/`False`, integers as digits, floats as the exact expansion, blank values never indexed), so an empty or whitespace string is no longer a value. A new `roles` map names the many-row selection aliases (never indexed for lookup, same catalog `index_alias`). Classify every `indexes` key per the README before updating. The verdict is the contract, the text is informational. -- A single-file store written as `cultcache.store.v3` is read and written: a record that holds an element id (an owned list element with an `[CultElementId]` member, carried through every write) marks the store v3, and a v3 store is carried through a rewrite with its ids. A runtime from before this change refuses a v3 store by name. +- A single-file store written as `cultcache.store.v3` is read and written: a store the C# runtime marked v3 (it holds a record with an element id: an owned list element with a `[CultElementId]` member) keeps its header here, and is carried through every rewrite with its ids; this runtime never drops the mark. A runtime from before this change refuses a v3 store by name. Rolling back to an older release after a store has been written as v3 is one-way: there is no carry-back, so restore from a copy taken before upgrading. - A write over a file the reader refuses (`StoreUnreadableError`, from `push`, `push_all` and `delete`) refuses and leaves the file as it is. A flush used to replace such a file. A write set cannot merge with a disk it cannot read, so the file is repaired or removed by its owner before the store writes again. - Added `StoreUnreadableError` (a `ValueError`) for a store file this runtime cannot read, and `SchemaConflictError` (a `ValueError`) for a write the catalog cannot describe. A store is exactly three slots; a schema is never recovered from a record's payload. diff --git a/packages/cultcache-py/README.md b/packages/cultcache-py/README.md index ab586ff6..236009ed 100644 --- a/packages/cultcache-py/README.md +++ b/packages/cultcache-py/README.md @@ -139,6 +139,10 @@ used by the TypeScript, Rust, and C# runtimes: Large corpora should use a sharded store or a real database. A single snapshot file is a scalpel, not a forklift. +## Store format and rollback + +A store whose elements hold an id is marked `cultcache.store.v3` by the C# runtime. This runtime reads and writes a v3 single-file store, keeps the header it read and carries the ids through a rewrite; it never drops the mark, so it has no carry-back. A cultcache-py from before element ids refuses a v3 store by name, so rolling back to it after a store has been written as v3 is one-way: restore from a copy taken before upgrading, and upgrade every reader before any writer. (The C# line is different: CultLib 1.x does not refuse; see the GameCult.Caching README.) + ## DatabaseEntry Slot Contract For cross-runtime cache entries, use `define_database_entry_type(...)` instead diff --git a/packages/cultcache-rs/CHANGELOG.md b/packages/cultcache-rs/CHANGELOG.md index f136bccf..15011491 100644 --- a/packages/cultcache-rs/CHANGELOG.md +++ b/packages/cultcache-rs/CHANGELOG.md @@ -8,7 +8,7 @@ only in git history. ### Breaking -- A single-file store written as `cultcache.store.v3` is read and written: a record that holds an element id (an owned list element with an `[CultElementId]` member, carried through every write) marks the store v3, and a v3 store is carried through a rewrite with its ids. A runtime from before this change refuses a v3 store by name. +- A single-file store written as `cultcache.store.v3` is read and written: a store the C# runtime marked v3 (it holds a record with an element id: an owned list element with a `[CultElementId]` member) keeps its header here, and is carried through every rewrite with its ids; this runtime never drops the mark. A runtime from before this change refuses a v3 store by name. Rolling back to an older release after a store has been written as v3 is one-way: there is no carry-back, so restore from a copy taken before upgrading. - A write over a file the reader refuses (a zero-byte file, a truncated store, a stamp this runtime does not read) refuses with `CultCacheStoreUnreadable` and leaves the file as it is. A whole-store write used to replace such a file. This reverses 0.3.1, where `push_all` replaced a file with no readable stamp (healing it); the stamp rule now has one owner, the reader every write asks under the lock, and it accepts v1 and v3 (0.3.1 refused v3). - `#[cultcache(index)]` means unique, as in C# `RefuseIndexSharing`: within one type and one alias no two records hold one non-blank value. A put, batch, push or load that would make two records hold a value is refused, naming both keys and the alias, and a store that already holds a duplicate does not open. A grouping index is no longer expressible; a consumer that relied on shared values must drop the `index` and look up by another way. - `bool` index and lookup text renders `True` / `False`, as C# does, where it rendered `true` / `false`. diff --git a/packages/cultcache-rs/README.md b/packages/cultcache-rs/README.md index 1dab4fc7..45724843 100644 --- a/packages/cultcache-rs/README.md +++ b/packages/cultcache-rs/README.md @@ -394,6 +394,10 @@ fn outcome(error: &anyhow::Error) { } ``` +## Store format and rollback + +A store whose elements hold an id is marked `cultcache.store.v3` by the C# runtime. This runtime reads and writes a v3 single-file store, keeps the header it read and carries the ids through a rewrite; it never drops the mark, so it has no carry-back. A cultcache-rs from before element ids refuses a v3 store by name, so rolling back to it after a store has been written as v3 is one-way: restore from a copy taken before upgrading, and upgrade every reader before any writer. (The C# line is different: CultLib 1.x does not refuse; see the GameCult.Caching README.) + ## Near-Term Ergonomic Improvements 1. **Derive macro** diff --git a/packages/cultcache-ts/CHANGELOG.md b/packages/cultcache-ts/CHANGELOG.md index 9850dfe4..1d0e81a2 100644 --- a/packages/cultcache-ts/CHANGELOG.md +++ b/packages/cultcache-ts/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following ### Breaking +- A single-file store marked `cultcache.store.v3` (it holds an element id, written by the C# runtime) is read, and a rewrite keeps the mark and the ids; this runtime does not decide when a document carries ids. A runtime from before this change refuses a v3 store by name, so rolling back to an older release after a store has been written as v3 is one-way: there is no carry-back, so restore from a copy taken before upgrading. - `indexes` are unique, as C# `[CultIndex]`: a write or a load that leaves two records of a type holding one non-blank value under one alias is refused naming both keys and the alias, and writes nothing. Index text is the C# canonical text (booleans `True`/`False`, integers as digits, doubles as the exact expansion, blank values never indexed), so @@ -27,7 +28,7 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following ### Breaking -- A single-file store written as `cultcache.store.v3` is read and written: a record that holds an element id (an owned list element with an `[CultElementId]` member, carried through every write) marks the store v3, and a v3 store is carried through a rewrite with its ids. A runtime from before this change refuses a v3 store by name. +- A single-file store written as `cultcache.store.v3` is read and written: a store the C# runtime marked v3 (it holds a record with an element id: an owned list element with a `[CultElementId]` member) keeps its header here, and is carried through every rewrite with its ids; this runtime never drops the mark. A runtime from before this change refuses a v3 store by name. - A single-file store refuses a zero-byte file as `StoreUnreadableError`; only a path with nothing at it is an empty store. A header this runtime does not read is refused, and the refusal echoes the header only as `cultcache.store.v`. - A write over a file the reader refuses (`StoreUnreadableError`, from `push`, `pushAll` and `delete`) refuses and leaves the file as it is. A flush used to replace such a file. A write set cannot merge with a disk it cannot read, so the file is repaired or removed by its owner before the store writes again. A full `pushAll` over a file with no readable stamp does not replace it: the reader refuses it. The stamp rule has one owner, `refuseUnreadFormat`, asked only by the one reader that every write consults under the lock. The reader judges the stamp from the header before it decodes the body, so a newer store is refused by name whether or not its body is whole. - A dangling symbolic link at the store path is an I/O error with code `ENOENT`, not an empty store. diff --git a/packages/cultcache-ts/README.md b/packages/cultcache-ts/README.md index 97fb3cdd..21e76701 100644 --- a/packages/cultcache-ts/README.md +++ b/packages/cultcache-ts/README.md @@ -63,6 +63,10 @@ const payload = encodeDocumentPayload(noteDocument, note); // DocumentPayload Browsers import the same names from `@gamecult/cultcache-ts/document`, which holds no Node built-ins. The whole cache without the single-file store is `@gamecult/cultcache-ts/browser`, which is also what bundlers resolve for the package root (`browser` condition); bring your own `CacheBackingStore`. +## Store format and rollback + +A store whose elements hold an id is marked `cultcache.store.v3` by the C# runtime. This runtime reads a v3 store, keeps the header it read and carries the ids through a rewrite; it never drops the mark, so it has no carry-back. A cultcache-ts from before element ids refuses a v3 store by name, so rolling back to it after a store has been written as v3 is one-way: restore from a copy taken before upgrading, and upgrade every reader before any writer. (The C# line is different: CultLib 1.x does not refuse; see the GameCult.Caching README.) + ## `.cc` Inspection CultCache files may use the `.cc` extension. The bytes are still the canonical diff --git a/src/GameCult.Caching.MessagePack/README.md b/src/GameCult.Caching.MessagePack/README.md index d34490f7..5e7156b0 100644 --- a/src/GameCult.Caching.MessagePack/README.md +++ b/src/GameCult.Caching.MessagePack/README.md @@ -59,6 +59,10 @@ await cache.PullAllBackingStoresAsync(); - The raw CultNet document lane should treat these payload bytes as already blessed, not decode and re-encode them for sport. +## Store format and rollback + +A store whose elements hold an id is marked `cultcache.store.v3` (a directory store `v5`); the single-file and directory stores here write the mark from content. See the Store format and rollback section of the GameCult.Caching README. Refusal by name depends on the store kind and the pin. A **directory** store (`v5`) is refused by every Unity pin from `cultlib-unity-v1.0.57` and `caching-unity-v1.2.0` on (the earliest tags of each line whose `DirectoryMessagePackBackingStore` reads only the `v4` stamp: "only ... is readable"). A **single-file** store (`v3`) is refused only from `cultlib-unity-v2.0.0` and `caching-unity-v2.0.0`: `cultlib-unity-v1.0.61` and `caching-unity-v1.4.0` read the header string and never compare it. The NuGet line (`cultlib-v1.0.32`, the last `cultlib-v` tag) refuses neither: its directory store writes `cultcache.store.v1.directory` and never checks the stamp on read. Rolling back a single-file store to any of those pins reads it silently, drops the ids, and can rewrite the elements without them. That is the dangerous case. The only carry-back is narrow and lossy (a whole-store write that leaves no id in the store drops the mark and the ids); otherwise restore from a copy taken before upgrading, and upgrade every reader before any writer. + ## MessagePack source generator NuGet consumers get `[assembly: MessagePackAssumedFormattable(typeof(CultRecordRef<>))]` from the package, so a diff --git a/src/GameCult.Caching/CultElementIds.cs b/src/GameCult.Caching/CultElementIds.cs index 38dfaaae..97a183e9 100644 --- a/src/GameCult.Caching/CultElementIds.cs +++ b/src/GameCult.Caching/CultElementIds.cs @@ -399,7 +399,7 @@ namespace GameCult.Caching continue; if (shape.Derive == null && !IsRandomId(id!)) throw new CultElementIdException( - $"Record {rootPath}: element id '{id}' in the list at {path} is not 12 lowercase hex characters. An id you write by hand must be 12 lowercase hex; for readable ids, derive them from a member with [CultElementId(nameof(Member))].", + $"Record {rootPath}: element id '{id}' in the list at {path} is not 12 lowercase hex characters. An id you write by hand must be 12 lowercase hex. To keep an existing readable id, leave that member as it is and add a trailing keyed string member marked [CultElementId(nameof(Member))], where Member is the existing one; the element id is derived from it once and then persisted.", rootPath, path, id); if (!taken.Add(id!)) throw Duplicate(id!, path); diff --git a/src/GameCult.Caching/README.md b/src/GameCult.Caching/README.md index 471361dd..6defed61 100644 --- a/src/GameCult.Caching/README.md +++ b/src/GameCult.Caching/README.md @@ -228,6 +228,60 @@ the text of that member (a string or number on the same type, unique within its list), and a derived id may be any text. A readable name set by hand on a non-derived list id is refused; the same member outside a list is unconstrained. +### A type that already has a readable id + +If the element type already has a readable string id (`Name`, `Slug`, a catalog +key), leave it as it is. Add a trailing keyed string member marked +`[CultElementId(nameof(ExistingId))]`; the element id is derived from the +existing member on load or first write, and then persisted: + +```csharp +[MessagePackObject] +public sealed class Recipe +{ + [Key(0)] public string Name = string.Empty; // existing readable id + [Key(1)] public int Yield; + [Key(2)] [CultElementId(nameof(Name))] public string ElementId = string.Empty; // new, last +} +``` + +Marking `Name` itself `[CultElementId]` is refused (`Name` is not 12 lowercase +hex), and that is deliberate: + +- Minted and hand-written ids share one grammar, 12 lowercase hex, so they can + never collide; a derived id is the one readable form and is checked for + uniqueness within its list. +- An id the consumer can edit is not a stable override address. Variants address + an element by its id, so a field that is renamed in place would silently move + the overrides to a different element, or to none. +- A derived id is persisted once. Renaming `Name` later does not change + `ElementId`, so the element and every override that addresses it stay put. + `ElementId` is the address; `Name` goes back to being ordinary data. + +Adopting the recipe on data that already exists: the existing member becomes an +id source, so it must be non-empty and unique within its list. A record with a +duplicate value and an empty one both refuse to load +(`CultElementIdException`), so clean such data first. + +Take the next free `[Key]` slot, after the highest key on the type (and above +the subclasses' keys on a union base). + +## Store format and rollback + +A store that holds an element id is written as `cultcache.store.v3` (a directory +store as `cultcache.store.v5.directory-content-addressed-pages`, `v4` otherwise). +A single-file store that holds a variant but no id is `v2`, and a store with +neither stays `v1`. The mark follows content: a whole-store write that leaves no +element holding an id drops it. + +Refusal by name depends on the store kind and the pin. A **directory** store (`v5`) is refused by every Unity pin from `cultlib-unity-v1.0.57` and `caching-unity-v1.2.0` on (the earliest tags of each line whose `DirectoryMessagePackBackingStore` reads only the `v4` stamp: "only ... is readable"). A **single-file** store (`v3`) is refused only from `cultlib-unity-v2.0.0` and `caching-unity-v2.0.0`: `cultlib-unity-v1.0.61` and `caching-unity-v1.4.0` read the header string and never compare it. The NuGet line (`cultlib-v1.0.32`, the last `cultlib-v` tag) refuses neither: its directory store writes `cultcache.store.v1.directory` and never checks the stamp on read. Rolling back a single-file store to any of those pins reads it silently, drops the ids, and can rewrite the elements without them. That is the dangerous case. + +Rolling a consumer back after it has written a v3 or v5 store is therefore +one-way. The only carry-back is narrow and lossy: a whole-store write that leaves +no id anywhere in the store (every record rewritten without list elements) writes +the store unmarked again, and the ids are gone. Otherwise restore from a copy +taken before upgrading. Upgrade every reader before any writer. + ## Persistence discipline Persistence stays manual by default. Mutations mark the cache and attached diff --git a/tests/GameCult.Caching.Tests/ElementIdEngineTests.cs b/tests/GameCult.Caching.Tests/ElementIdEngineTests.cs index 2bc1d9df..a54251ed 100644 --- a/tests/GameCult.Caching.Tests/ElementIdEngineTests.cs +++ b/tests/GameCult.Caching.Tests/ElementIdEngineTests.cs @@ -142,7 +142,7 @@ namespace GameCult.Caching.Tests public void TheHexRefusalNamesTheDerivedFormForReadableIds() { var refusal = Assert.Throws(() => Assign(new HHasIds { Items = { new EIded { Id = "root" } } }, "r", deterministic: false))!; - Assert.That(refusal.Message, Does.Contain("12 lowercase hex").And.Contain("[CultElementId(nameof(Member))]")); + Assert.That(refusal.Message, Does.Contain("12 lowercase hex").And.Contain("[CultElementId(nameof(Member))]").And.Contain("trailing keyed string member").And.Contain("existing readable id")); } [Test] diff --git a/tests/GameCult.Caching.Tests/ElementIdTests.cs b/tests/GameCult.Caching.Tests/ElementIdTests.cs index 1b6b3fe2..a6c68c94 100644 --- a/tests/GameCult.Caching.Tests/ElementIdTests.cs +++ b/tests/GameCult.Caching.Tests/ElementIdTests.cs @@ -389,6 +389,44 @@ namespace GameCult.Caching.Tests Assert.That(refusal.Message, Does.Contain("'7'")); } + // The documented recipe for a type that already has a readable id (src/GameCult.Caching/README.md, Element ids). + [Test] + public void ATrailingMemberDerivedFromAnExistingReadableIdIsPersistedOnceAndTheStoreIsMarkedV3() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(RecipeBook) }); + var path = PathOf("recipes.cc"); + var key = new CultRecordKey("book"); + using (var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = registry })) + cache.Commit(batch => batch.Upsert(typeof(RecipeBook), new RecipeBook { Recipes = { new IdRecipe { Name = "bread" }, new IdRecipe { Name = "soup" } } }, key)); + Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v3")); + + using (var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = registry })) + { + var book = cache.Get(key)!; + Assert.That(book.Recipes.Select(recipe => recipe.ElementId), Is.EqualTo(new[] { "bread", "soup" })); + book.Recipes[0].Name = "loaf"; + cache.Commit(batch => batch.Upsert(typeof(RecipeBook), book, key)); + } + + using var again = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = registry }); + var reread = again.Get(key)!; + Assert.That(reread.Recipes.Select(recipe => recipe.Name), Is.EqualTo(new[] { "loaf", "soup" })); + Assert.That(reread.Recipes.Select(recipe => recipe.ElementId), Is.EqualTo(new[] { "bread", "soup" }), "renaming the source does not move the element"); + } + + [Test] + public void AWholeStoreWriteThatLeavesNoIdInTheStoreDropsTheMarkBackToV1() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(RecipeBook) }); + var path = PathOf("drop.cc"); + var key = new CultRecordKey("book"); + using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = registry }); + cache.Commit(batch => batch.Upsert(typeof(RecipeBook), new RecipeBook { Recipes = { new IdRecipe { Name = "bread" } } }, key)); + Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v3")); + cache.Commit(batch => batch.Upsert(typeof(RecipeBook), new RecipeBook(), key)); + Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v1"), "the mark follows content: no element holds an id, so the store is unmarked"); + } + // ---- C2a fix batch: the marker, refusals at load, override values, atomic minting ---- private const string HexA = "aaaaaaaaaaaa"; @@ -1359,6 +1397,20 @@ namespace GameCult.Caching.Tests [Key(1)] public List Labels { get; set; } = new(); } + [CultDocument("tests.element_id_recipe_book", "tests.element_id_recipe_book.v1")] + [MessagePackObject] + public sealed class RecipeBook + { + [Key(0)] public List Recipes { get; set; } = new(); + } + + [MessagePackObject] + public sealed class IdRecipe + { + [Key(0)] public string Name { get; set; } = ""; + [Key(1)] [CultElementId(nameof(Name))] public string ElementId { get; set; } = ""; + } + [MessagePackObject] public sealed class IdLabel { diff --git a/unity/org.gamecult.cultlib/CHANGELOG.md b/unity/org.gamecult.cultlib/CHANGELOG.md index ef98338b..3d03439a 100644 --- a/unity/org.gamecult.cultlib/CHANGELOG.md +++ b/unity/org.gamecult.cultlib/CHANGELOG.md @@ -10,7 +10,7 @@ Releases as 3.0.0 (major). - A single-file store flush refuses a file it cannot read (empty, truncated, an unreadable stamp or body) with `CultStoreUnreadableException` and leaves the file as it is. A flush used to replace such a file. A write set cannot merge with a disk it cannot read, so the file is repaired or removed by its owner before the store writes again. - A single-file store reads v1, v2 and v3 stamps and refuses v4 and later by name; a directory store reads v4 and v5 and refuses v6 and later by name. Each judges the stamp before the body, so an unreadable body never makes a newer store look like damage. -- A store is written in the format its content needs, and CultLib 2.0 refuses the newer ones by name. A single-file store holding a document variant is written as `cultcache.store.v2`, one holding an element id as `cultcache.store.v3`; a directory store holding an element id is written as `cultcache.store.v5` (`v4` otherwise). A store is marked only when a record actually holds an id or a variant. Upgrade readers before writers: a 2.0 reader that opens such a store is refused, not misread. +- A store is written in the format its content needs, and CultLib 2.0 refuses the newer single-file ones by name. A single-file store holding a document variant is written as `cultcache.store.v2`, one holding an element id as `cultcache.store.v3`; a directory store holding an element id is written as `cultcache.store.v5` (`v4` otherwise). A store is marked only when a record actually holds an id or a variant. Upgrade readers before writers: a 2.0 reader that opens such a store is refused, not misread. Rolling back to a 2.0 pin after a store has been written as v3 or v5 is one-way: the only carry-back is narrow and lossy (a whole-store write that leaves no element holding an id writes the store unmarked, and the ids are gone), so restore from a copy taken before upgrading. Refusal by name is not uniform in 1.x: a v5 directory store is refused from `cultlib-unity-v1.0.57` (`caching-unity-v1.2.0`), but a v3 single-file store is read silently by every pin before `cultlib-unity-v2.0.0` (including `cultlib-unity-v1.0.61`, `caching-unity-v1.4.0`, `cultlib-v1.0.32`), which drops the ids and can rewrite the elements without them. - Writes are write sets. A flush or commit replaces the records it staged and copies every other record forward as stored, under the store's lock, instead of rewriting the whole store from the cache's memory. A record the cache does not know is kept, and an untouched record keeps a dropped member's slot through every write (never reuse a dropped slot; see the schema-compatibility contract). - A write or removal at a key whose stored record the writer does not own is refused with `CultSchemaConflictException`, and the file is left as it was. A writer owns a record whose schema id it declares (owns or lists compatible) and a record its type reads whole under compatible drift: that record is writable and is restamped under the type's own id on write, as in 2.0.1. A record a type reads only by dropping persisted slots stays read-only until its id is declared compatible, which accepts the loss (this differs from 2.0.1, which shed the slots silently). A cache owns through its registry; a cacheless CultMesh write owns through the catalog entry it writes. - A write replaces the record at a key only when the type it writes there owns that record, decided per key and the same for a cache and for a cacheless `WriteDocument`, `WriteRecord` or unattached-store `PushAll`, in a single-file store and a directory store. The written type owns the stored record when its entry declares the stored id (its own id, or one it lists compatible), or when it is the type that reads the record's published entry and reads it whole (no persisted slot unread): an older id of its own schema name that it reads whole is its own and is restamped, as in 2.0.1. Anything else is refused with `CultSchemaConflictException` and the file left as it was: a record of another registered type, under its current id or an old id it lists, and a record the written type would read only by dropping persisted slots. No registry enters the decision, `CultDocumentRegistry.Shared` included, so a lent registry never changes the answer. This changes a cache: where 2.x let a cache holding two types replace one's record at a key with the other's, it now refuses, with or without a backing store. Retype a key by removing it and adding the new record, in one batch (`batch.Remove(key)` then `batch.Upsert(...)`) or staged apart (`Remove` then `UpsertAsync`); the removal is judged as a removal, so a record no registered type owns is still refused. A removal or check goes to the registry's removal rule, and a cacheless call, which has no registry, removes a record only when an entry it writes owns it (a `Delete` then `PushAll` on an unattached store writes no entry and is refused). From 23d254a501e58e5c4f7dad43f7ef6cad2395178d Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 10:05:32 +0000 Subject: [PATCH 17/93] CultCache: a unique-index refusal names the least violation; shared refusals vector (#99) --- src/GameCult.Caching/CultCache.cs | 36 ++- .../UniqueIndexVectorTests.cs | 301 ++++++++++++++++++ tests/vectors/unique-index/README.md | 45 +++ tests/vectors/unique-index/alias-store.cc | Bin 0 -> 1060 bytes tests/vectors/unique-index/key-case-store.cc | Bin 0 -> 1177 bytes .../unique-index/key-punctuation-store.cc | Bin 0 -> 1177 bytes tests/vectors/unique-index/pair-store.cc | Bin 0 -> 1056 bytes tests/vectors/unique-index/refusals.msgpack | Bin 0 -> 2676 bytes tests/vectors/unique-index/repull-after.cc | Bin 0 -> 1201 bytes tests/vectors/unique-index/repull-before.cc | Bin 0 -> 955 bytes .../unique-index/surrogate-groups-store.cc | Bin 0 -> 1189 bytes tests/vectors/unique-index/surrogate-store.cc | Bin 0 -> 1070 bytes tests/vectors/unique-index/types-store.cc | Bin 0 -> 1844 bytes 13 files changed, 373 insertions(+), 9 deletions(-) create mode 100644 tests/vectors/unique-index/alias-store.cc create mode 100644 tests/vectors/unique-index/key-case-store.cc create mode 100644 tests/vectors/unique-index/key-punctuation-store.cc create mode 100644 tests/vectors/unique-index/pair-store.cc create mode 100644 tests/vectors/unique-index/refusals.msgpack create mode 100644 tests/vectors/unique-index/repull-after.cc create mode 100644 tests/vectors/unique-index/repull-before.cc create mode 100644 tests/vectors/unique-index/surrogate-groups-store.cc create mode 100644 tests/vectors/unique-index/surrogate-store.cc create mode 100644 tests/vectors/unique-index/types-store.cc diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index 709fc64d..6f04d99c 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -3495,10 +3495,11 @@ namespace GameCult.Caching } } - // The one index rule, judged on the commit's result whatever the write order: within one document type and one - // [CultIndex] alias, no two records hold one non-blank value. The holders of a value are the held key (unless this - // admission replaces or evicts it) plus every landing key projecting it; two distinct holders refuse, naming both - // keys and the index and never the value. One pass over what lands, one lookup per (index, value). + // The one index rule, a function of the state the admission would leave and never of staging, file or pull order: + // within one document type and one [CultIndex] alias, no two records hold one non-blank value. The holders of a + // value are the held key (unless this admission replaces or evicts it) plus every landing key projecting it. Over + // every (type, alias, value) held at two or more distinct keys, the refusal names the least by (alias, its least + // key, its second-least key, schema name), each compared by UTF-16 code unit, and never names the value. private void RefuseIndexSharing(VariantPlan plan, IReadOnlyList evicted) { var landing = plan.Admitted.Concat(plan.Dependents).ToList(); @@ -3520,17 +3521,34 @@ namespace GameCult.Caching } } - foreach (var ((index, value), landedKeys) in landed.OrderBy(pair => pair.Key.Index.Alias, StringComparer.Ordinal)) + (string Alias, string First, string Second, string Schema)? least = null; + foreach (var ((index, value), landedKeys) in landed) { var holders = landedKeys.AsEnumerable(); if (_indexes.TryGetValue(index, out var held) && held.TryGetValue(value, out var heldKey) && !replaced.Contains(heldKey)) holders = holders.Append(heldKey); var distinct = holders.Distinct(StringComparer.Ordinal).OrderBy(key => key, StringComparer.Ordinal).Take(2).ToArray(); - if (distinct.Length > 1) - throw new InvalidOperationException( - $"Records {distinct[0]} and {distinct[1]} ({schemaNames[index.Type]}) " + - $"both hold unique index '{index.Alias}'; an index value names one record."); + if (distinct.Length < 2) + continue; + var candidate = (index.Alias, distinct[0], distinct[1], schemaNames[index.Type]); + if (least == null || CompareRefusals(candidate, least.Value) < 0) + least = candidate; } + + if (least is { } pair) + throw new InvalidOperationException( + $"Records {pair.First} and {pair.Second} ({pair.Schema}) " + + $"both hold unique index '{pair.Alias}'; an index value names one record."); + } + + private static int CompareRefusals( + (string Alias, string First, string Second, string Schema) left, + (string Alias, string First, string Second, string Schema) right) + { + var order = StringComparer.Ordinal.Compare(left.Alias, right.Alias); + if (order == 0) order = StringComparer.Ordinal.Compare(left.First, right.First); + if (order == 0) order = StringComparer.Ordinal.Compare(left.Second, right.Second); + return order != 0 ? order : StringComparer.Ordinal.Compare(left.Schema, right.Schema); } private static InvalidOperationException CycleRefusal(IEnumerable keys) => diff --git a/tests/GameCult.Caching.Tests/UniqueIndexVectorTests.cs b/tests/GameCult.Caching.Tests/UniqueIndexVectorTests.cs index d7c68459..44aef30d 100644 --- a/tests/GameCult.Caching.Tests/UniqueIndexVectorTests.cs +++ b/tests/GameCult.Caching.Tests/UniqueIndexVectorTests.cs @@ -290,5 +290,306 @@ namespace GameCult.Caching.Tests } } } + + // ---- refusals.msgpack: [label, ops, refusal]; the pair a refusal names is a function of the resulting state ---- + + private sealed record Schema(string Name, Type Role, Type Index, string SecondField); + + private static Schema Plain(string suffix, string name) => new( + name, + EmittedDocumentTypes.Emit("UqVec" + suffix + "Role", name, name + ".v1", RoleFields), + EmittedDocumentTypes.Emit("UqVec" + suffix + "Index", name, name + ".v1", IndexFields), + "Category"); + + private static readonly Schema[] Schemas = + { + new("tests.unique_item", ItemRole, ItemIndex, "Category"), + new("tests.uq_pair", + EmittedDocumentTypes.Emit("UqVecPairRole", "tests.uq_pair", "tests.uq_pair.v1", + new EmittedDocumentTypes.Field[] { new("Sku", typeof(string), 0, Role: "Sku"), new("Alt", typeof(string), 1, Role: "Alt") }), + EmittedDocumentTypes.Emit("UqVecPairIndex", "tests.uq_pair", "tests.uq_pair.v1", + new EmittedDocumentTypes.Field[] { new("Sku", typeof(string), 0, Index: "Sku"), new("Alt", typeof(string), 1, Index: "Alt") }), + "Alt"), + new("tests.uq_alias", + EmittedDocumentTypes.Emit("UqVecAliasRole", "tests.uq_alias", "tests.uq_alias.v1", + new EmittedDocumentTypes.Field[] { new("Sku", typeof(string), 0, Role: "Sku"), new("Alt", typeof(string), 1, Role: "alt") }), + EmittedDocumentTypes.Emit("UqVecAliasIndex", "tests.uq_alias", "tests.uq_alias.v1", + new EmittedDocumentTypes.Field[] { new("Sku", typeof(string), 0, Index: "Sku"), new("Alt", typeof(string), 1, Index: "alt") }), + "Alt"), + Plain("Alpha", "tests.uq_alpha"), + Plain("Beta", "tests.uq_beta"), + }; + + private static Schema SchemaNamed(string name) => Schemas.Single(schema => schema.Name == name); + + private sealed record Rec(string Key, string Schema, string Sku, string Second = ""); + + private sealed record Op(string Kind, string? File = null, Rec[]? Records = null); + + private sealed record Refusal(string K1, string K2, string Schema, string Alias); + + private sealed record Case(string Label, Op[] Ops, Refusal? Refusal); + + private const string Item = "tests.unique_item"; + private const string Pair = "tests.uq_pair"; + private const string Alias = "tests.uq_alias"; + private const string Alpha = "tests.uq_alpha"; + private const string Beta = "tests.uq_beta"; + + private static Rec[] Items_(params (string Key, string Sku)[] rows) => rows.Select(row => new Rec(row.Key, Item, row.Sku)).ToArray(); + private static Op Open(string? file = null) => new("open", file); + private static Op Pull(string file) => new("pull", file); + private static Op Commit(params Rec[] records) => new("commit", Records: records); + + private static readonly Rec[] PairRecords = { new("item:a", Pair, "S", "X"), new("item:b", Pair, "S", "Y"), new("item:c", Pair, "T", "Y") }; + private static readonly Rec[] SurrogateRecords = Items_(("k\uFF01", "S"), ("k\U0001F600", "S"), ("k\uE000", "S")); + private static readonly Rec[] TypeRecords = + { + new("b:1", Alpha, "P"), new("a:1", Beta, "P"), new("b:2", Alpha, "P"), new("a:2", Beta, "P") + }; + private static readonly Rec[] AliasRecords = { new("item:a", Alias, "S", "X"), new("item:b", Alias, "S", "Y"), new("item:c", Alias, "T", "Y") }; + private static readonly Rec[] RepullBefore = Items_(("item:a1", "A"), ("item:b1", "C")); + private static readonly Rec[] RepullAfter = Items_(("item:a1", "A"), ("item:b1", "B"), ("item:b2", "B"), ("item:c1", "A")); + private static readonly Rec[] KeyCaseRecords = Items_(("a", "S"), ("z", "S"), ("B", "T"), ("C", "T")); + private static readonly Rec[] KeyPunctuationRecords = Items_(("_", "S"), ("a", "S"), ("Z", "T"), ("b", "T")); + private static readonly Rec[] SurrogateGroupRecords = Items_(("\uE000", "S"), ("\uE000b", "S"), ("\U0001F600", "T"), ("\U0001F600b", "T")); + + private static readonly (string File, Rec[] Records)[] RefusalStores = + { + ("pair-store.cc", PairRecords), ("surrogate-store.cc", SurrogateRecords), ("types-store.cc", TypeRecords), + ("repull-before.cc", RepullBefore), ("repull-after.cc", RepullAfter), ("alias-store.cc", AliasRecords), + ("key-case-store.cc", KeyCaseRecords), ("key-punctuation-store.cc", KeyPunctuationRecords), + ("surrogate-groups-store.cc", SurrogateGroupRecords), + }; + + private static readonly Refusal SurrogateRefusal = new("k\U0001F600", "k\uE000", Item, "Sku"); + private static readonly Refusal TypesRefusal = new("a:1", "a:2", Beta, "Sku"); + private static readonly Refusal PairRefusal = new("item:b", "item:c", Pair, "Alt"); + private static readonly Refusal AliasRefusal = new("item:a", "item:b", Alias, "Sku"); + private static readonly Refusal RepullRefusal = new("item:a1", "item:c1", Item, "Sku"); + private static readonly Refusal KeyCaseRefusal = new("B", "C", Item, "Sku"); + private static readonly Refusal KeyPunctuationRefusal = new("Z", "b", Item, "Sku"); + private static readonly Refusal SurrogateGroupRefusal = new("\U0001F600", "\U0001F600b", Item, "Sku"); + + private static readonly Case[] Cases = + { + new("batch-later-value-staged-first", new[] { Open(), Commit(Items_(("x", "A"), ("y", "A"), ("a", "B"), ("b", "B"))) }, + new Refusal("a", "b", Item, "Sku")), + new("batch-surrogate-keys", new[] { Open(), Commit(SurrogateRecords) }, SurrogateRefusal), + new("batch-interleaved-types", new[] { Open(), Commit(TypeRecords) }, TypesRefusal), + new("batch-two-aliases", new[] { Open(), Commit(PairRecords) }, PairRefusal), + new("held-then-landing", new[] { Open(), Commit(Items_(("m", "S"))), Commit(Items_(("a", "S"))) }, + new Refusal("a", "m", Item, "Sku")), + new("held-in-the-least-group", new[] { Open(), Commit(Items_(("m", "A"))), Commit(Items_(("z", "B"), ("y", "B"), ("a", "A"))) }, + new Refusal("a", "m", Item, "Sku")), + new("swap-off-a-held-value", new[] { Open(), Commit(Items_(("a1", "A"))), Commit(Items_(("a1", "B"), ("c1", "A"))) }, null), + new("load-two-aliases", new[] { Open("pair-store.cc") }, PairRefusal), + new("load-surrogate-keys", new[] { Open("surrogate-store.cc") }, SurrogateRefusal), + new("load-interleaved-types", new[] { Open("types-store.cc") }, TypesRefusal), + new("repull-held-pair", new[] { Open("repull-before.cc"), Pull("repull-after.cc") }, RepullRefusal), + new("load-repull-after", new[] { Open("repull-after.cc") }, RepullRefusal), + new("commit-onto-a-loaded-holder", new[] { Open("repull-before.cc"), Commit(Items_(("item:z", "A"))) }, + new Refusal("item:a1", "item:z", Item, "Sku")), + // Rows 14-19 extend the spec's floor of 13: orderings a code-point, case-insensitive or culture compare would flip. + new("batch-key-case", new[] { Open(), Commit(KeyCaseRecords) }, KeyCaseRefusal), + new("batch-key-punctuation", new[] { Open(), Commit(KeyPunctuationRecords) }, KeyPunctuationRefusal), + new("batch-key-case-in-a-group", new[] { Open(), Commit(Items_(("a", "S"), ("B", "S"))) }, + new Refusal("B", "a", Item, "Sku")), + new("batch-surrogate-groups", new[] { Open(), Commit(SurrogateGroupRecords) }, SurrogateGroupRefusal), + new("batch-alias-case", new[] { Open(), Commit(AliasRecords) }, AliasRefusal), + new("load-alias-case", new[] { Open("alias-store.cc") }, AliasRefusal), + // Rows 20-22 load rows 14, 15 and 17's records, so a runtime with no batch commit still walks cross-group key order. + new("load-key-case", new[] { Open("key-case-store.cc") }, KeyCaseRefusal), + new("load-key-punctuation", new[] { Open("key-punctuation-store.cc") }, KeyPunctuationRefusal), + new("load-surrogate-groups", new[] { Open("surrogate-groups-store.cc") }, SurrogateGroupRefusal), + }; + + private static byte[] RefusalStoreBytes(Rec[] records) + { + var directory = Path.Combine(Path.GetTempPath(), $"cultlib-uidxr-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + try + { + var path = Path.Combine(directory, "store.cc"); + var roles = records.Select(record => SchemaNamed(record.Schema).Role).Distinct().ToArray(); + using (var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions + { + Registry = CultDocumentRegistry.ForTypes(roles), StoreFlushOnDispose = true + })) + { + cache.Clock = () => StoredAt; + foreach (var record in records) + { + var schema = SchemaNamed(record.Schema); + cache.UpsertAsync(schema.Role, EmittedDocumentTypes.New(schema.Role, ("Sku", record.Sku), (schema.SecondField, record.Second)), new CultRecordKey(record.Key)) + .GetAwaiter().GetResult(); + } + + cache.FlushAsync().GetAwaiter().GetResult(); + } + + return File.ReadAllBytes(path); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + private static byte[] RefusalsFile() + { + var buffer = new ArrayBufferWriter(); + var writer = new MessagePackWriter(buffer); + writer.WriteArrayHeader(Cases.Length); + foreach (var row in Cases) + { + writer.WriteArrayHeader(3); + writer.Write(row.Label); + writer.WriteArrayHeader(row.Ops.Length); + foreach (var op in row.Ops) + { + writer.WriteArrayHeader(2); + writer.Write(op.Kind); + if (op.Records != null) + { + writer.WriteArrayHeader(op.Records.Length); + foreach (var record in op.Records) + { + writer.WriteArrayHeader(4); + writer.Write(record.Key); + writer.Write(record.Schema); + writer.Write(record.Sku); + writer.Write(record.Second); + } + } + else if (op.File == null) writer.WriteNil(); + else writer.Write(op.File); + } + + if (row.Refusal == null) writer.WriteNil(); + else + { + writer.WriteArrayHeader(4); + writer.Write(row.Refusal.K1); + writer.Write(row.Refusal.K2); + writer.Write(row.Refusal.Schema); + writer.Write(row.Refusal.Alias); + } + } + + writer.Flush(); + return buffer.WrittenSpan.ToArray(); + } + + // Performs one op of a row against the open cache; the cache's own file is the only state it leaves behind. + private static void Perform(ref CultCache? cache, string path, Op op) + { + var indexTypes = Schemas.Select(schema => schema.Index).ToArray(); + switch (op.Kind) + { + case "open": + if (op.File != null) File.WriteAllBytes(path, Committed(op.File)); + cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = CultDocumentRegistry.ForTypes(indexTypes) }); + break; + case "pull": + File.WriteAllBytes(path, Committed(op.File!)); + cache!.PullAllBackingStoresAsync().GetAwaiter().GetResult(); + break; + case "commit": + cache!.Commit(batch => + { + foreach (var record in op.Records!) + { + var schema = SchemaNamed(record.Schema); + batch.Upsert(schema.Index, EmittedDocumentTypes.New(schema.Index, ("Sku", record.Sku), (schema.SecondField, record.Second)), new CultRecordKey(record.Key)); + } + }); + break; + default: + throw new InvalidOperationException(op.Kind); + } + } + + [Test] + public void RefusalsHoldTheRule() + { + Check("refusals.msgpack", RefusalsFile()); + foreach (var (file, records) in RefusalStores) + Check(file, RefusalStoreBytes(records)); + + // The committed file is what is walked, not the in-memory table. + var reader = new MessagePackReader(Committed("refusals.msgpack")); + var count = reader.ReadArrayHeader(); + Assert.That(count, Is.EqualTo(Cases.Length)); + var walked = 0; + for (var i = 0; i < count; i++) + { + Assert.That(reader.ReadArrayHeader(), Is.EqualTo(3)); + var label = reader.ReadString()!; + var ops = new List(); + for (var n = reader.ReadArrayHeader(); n > 0; n--) + { + Assert.That(reader.ReadArrayHeader(), Is.EqualTo(2)); + var kind = reader.ReadString()!; + if (kind == "commit") + { + var records = new List(); + for (var m = reader.ReadArrayHeader(); m > 0; m--) + { + Assert.That(reader.ReadArrayHeader(), Is.EqualTo(4)); + records.Add(new Rec(reader.ReadString()!, reader.ReadString()!, reader.ReadString()!, reader.ReadString()!)); + } + + ops.Add(new Op(kind, Records: records.ToArray())); + } + else ops.Add(new Op(kind, NextText(ref reader))); + } + + Refusal? refusal = null; + if (!reader.TryReadNil()) + { + Assert.That(reader.ReadArrayHeader(), Is.EqualTo(4)); + refusal = new Refusal(reader.ReadString()!, reader.ReadString()!, reader.ReadString()!, reader.ReadString()!); + } + + var directory = Path.Combine(Path.GetTempPath(), $"cultlib-uidxf-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + CultCache? cache = null; + try + { + var path = Path.Combine(directory, "store.cc"); + foreach (var op in ops.Take(ops.Count - 1)) + Perform(ref cache, path, op); + + var last = ops[^1]; + if (refusal == null) + { + Assert.DoesNotThrow(() => Perform(ref cache, path, last), label); + } + else + { + cache?.FlushAsync().GetAwaiter().GetResult(); + var before = last.Kind == "open" && last.File != null ? Committed(last.File) + : last.Kind == "pull" ? Committed(last.File!) + : File.Exists(path) ? File.ReadAllBytes(path) : Array.Empty(); + CultCache? attempt = cache; + var refused = Assert.Throws(() => Perform(ref attempt, path, last), label)!; + Assert.That(refused.Message, Is.EqualTo( + $"Records {refusal.K1} and {refusal.K2} ({refusal.Schema}) both hold unique index '{refusal.Alias}'; an index value names one record."), label); + if (last.Kind != "open") cache?.FlushAsync().GetAwaiter().GetResult(); + Assert.That(File.Exists(path) ? File.ReadAllBytes(path) : Array.Empty(), Is.EqualTo(before), $"{label}: the file is untouched"); + } + + walked++; + } + finally + { + cache?.Dispose(); + Directory.Delete(directory, recursive: true); + } + } + + Assert.That(walked, Is.EqualTo(22)); + } } } diff --git a/tests/vectors/unique-index/README.md b/tests/vectors/unique-index/README.md index 548c8845..ac4a40cd 100644 --- a/tests/vectors/unique-index/README.md +++ b/tests/vectors/unique-index/README.md @@ -18,7 +18,52 @@ reviewed commit. Other runtimes are held to the same canonical text and the same stored-at pinned to `2026-01-01T00:00:00Z`. Records `item:a` and `item:b` both hold Sku `S-1`; `item:c` holds Sku `" "` and `item:d` holds Sku `U+0085`. Written through a role Sku. - `blank-store.cc`: the same, only `item:c` and `item:d`. +- `pair-store.cc`, `surrogate-store.cc`, `types-store.cc`, `repull-before.cc`, `repull-after.cc`, `alias-store.cc`, + `key-case-store.cc`, `key-punctuation-store.cc`, `surrogate-groups-store.cc`: single-file stores written like + `duplicate-store.cc`, through roles, for the rows of `refusals.msgpack` that open or pull them. Schemas + `tests.unique_item`, `tests.uq_pair` (Sku and Alt both unique), `tests.uq_alias` (Sku and `alt` both unique), + `tests.uq_alpha` and `tests.uq_beta`, each slots `[0: Sku string, 1: second string]`, schema version the name plus + `.v1`. The schema ids C# writes in the store catalogs and on every record: + - `tests.unique_item`: `sha256:93bb890de83069061af696b23f94fd32fb3c2dad3c91ee645e8add56085f1882` + - `tests.uq_pair`: `sha256:2538cceafb6dcbce22ef11b932ecb804af5f96bc3aaedff692a52d3640433e60` + - `tests.uq_alias`: `sha256:77a77a394ebd8bf71e14b9b6f2cea4978c25d4e705fd12364f660edbf6c63b1f` + - `tests.uq_alpha`: `sha256:53aeaf2ea9b25e85ade6b15fc04c7653e7b206c87e258f1aa74dd32a840a1389` + - `tests.uq_beta`: `sha256:45ed62a64c91f6d815d3ec975c4a4a1b2ce07b6e7262a32d828673cf10899586` - `outcomes.msgpack`: rows `[file, unique alias or nil, open|refused, k1 or nil, k2 or nil]`. Opening `file` with that alias a unique index (nil: a role, no unique alias) yields the verdict. A refusal reads, byte for byte, `Records and (tests.unique_item) both hold unique index ''; an index value names one record.` with `k1 < k2` ordinal, never contains the value, and leaves the file byte-identical. + +## refusals.msgpack + +RULE: a unique-index refusal is a function of the state the admission would leave, never of staging, file or pull +order. Over every (type, unique alias, non-blank value) that state holds at two or more distinct keys, name the one +least by (alias, its least key, its second-least key, schema name), each compared by UTF-16 code unit (C# +`StringComparer.Ordinal`): `Records and () both hold unique index ''; an index value names one +record.` with `k1 < k2` its two least keys. One rule for a single write, a batch, a load, a re-pull and an index +registration. + +Rows `[label, ops, refusal]`. Each op is `["open", file-or-nil]` (copy that vector file to a fresh path, or nil for a +fresh path with no file, and open a cache on it), `["pull", file]` (overwrite the open cache's file with that vector +file, then pull every backing store again) or `["commit", [[key, schema, sku, second], ...]]` (one batch, staged in row +order; schema is the schema name). `refusal` is nil (every op succeeds) or `[k1, k2, schema, alias]`: every op but the +last succeeds and the last refuses with exactly the RULE text built from those fields, leaving the file +byte-identical. Schemas are those of the store files above; `second` is `Category` (plain) except in `tests.uq_pair` +and `tests.uq_alias`, where it is `Alt` (unique, alias `Alt` and `alt` respectively). + +The 13 rows of the spec are a floor; rows 14-22 extend it with orderings that a code-point, case-insensitive or +culture compare would flip. Rows, in file order: 1 batch-later-value-staged-first, 2 batch-surrogate-keys, 3 +batch-interleaved-types, 4 batch-two-aliases, 5 held-then-landing, 6 held-in-the-least-group, 7 swap-off-a-held-value, +8 load-two-aliases, 9 load-surrogate-keys, 10 load-interleaved-types, 11 repull-held-pair, 12 load-repull-after, 13 +commit-onto-a-loaded-holder, 14 batch-key-case (`B` before `a`), 15 batch-key-punctuation (`Z` before `_` before `a`), +16 batch-key-case-in-a-group, 17 batch-surrogate-groups (U+1F600 before U+E000, the reverse of code-point order), 18 +batch-alias-case (alias `Sku` before `alt`), 19 load-alias-case, 20 load-key-case, 21 load-key-punctuation, 22 +load-surrogate-groups. Rows 19-22 open a store holding the records of rows 18, 14, 15 and 17, so a runtime with no batch +commit still walks alias and cross-group key order. + +A runtime performs every row its API can perform and asserts the count it walked: + +- C# and Rust: all 22. +- Python: 21, every row but 3 (`put_envelopes` takes one type). +- TypeScript: 11, rows 5, 8, 9, 10, 11, 12, 13, 19, 20, 21 and 22 (no batch commit, so no row 1-4, 6, 7 or 14-18; + `variants:idea:unique-index-runtimes.ts-batch-commit`). diff --git a/tests/vectors/unique-index/alias-store.cc b/tests/vectors/unique-index/alias-store.cc new file mode 100644 index 0000000000000000000000000000000000000000..3ea51ef5737d2275373395d084133ae2da86860e GIT binary patch literal 1060 zcmchV&u-K(5XRd>AB0@HM7HDDNp3xH<3Os4szTM+8MhHh7Gf{zs_H}Zk3_GlO58xA zPf)FRiSjheW|tK!xB-pkGM>rz`)2Ixdpl`@1$&CI591!=5B&1u;|qTZB2|UfK#}AU zO_dp^IdW-oqnxk^GS`_EX(ds!)Ky$0N;;(&R>mo-62sk>fIj$m^8Of_8vNJIlvryL z7h0wkm@u}=GHrOC=2o*@GtPz63M!!>1to<_@9Fvwhtemdc6 zCwoo!-%emaY&v;!ak;*)zkL~3w_A5K?dOd>&gT#F#p&(#9gX;h8o9c@`Bc;93;1VN r9-j#&)N{ru_8BXNh}rVn9kOpbSF>{VdWAMmk3L+X?Ng)oXNNxkwMSNA literal 0 HcmV?d00001 diff --git a/tests/vectors/unique-index/key-case-store.cc b/tests/vectors/unique-index/key-case-store.cc new file mode 100644 index 0000000000000000000000000000000000000000..b5032c8d4041f05dfb9dd52d09842bda4b0710b1 GIT binary patch literal 1177 zcmc(d%}(1u6oo z^#M@Sm!MC>B?%EI-IX6}HgkREeCOEn^G3C$Q=t0jTQ_LXKMYQO-EG@G@K~fW%=1Jt zjY-IaWFi0~q{w+_WMp*6O&%()L5E5Ph$4zH0j*=flGp@E!mk}#XZ^M=zO}etIIMmz zO^CSQMTs`3Arw3U7O^OoG2f3S|l^5hl`DariU(jlCy5hk`C4yk3Nsxnm*f!(ZJoBrUDl}UEXuA#IM)7tGot1!qE9~i{a{*M`;d@Pu73#Z*R)!`PL;h{{HA&&gZxD#q@mp+e6Jw!R>T9 w+kdhgcP}{IxLp>}8ZeeGSIAb+%jNFJ&gpD$F`XSgH?$xq{12#lZs_3R7daGo4gdfE literal 0 HcmV?d00001 diff --git a/tests/vectors/unique-index/pair-store.cc b/tests/vectors/unique-index/pair-store.cc new file mode 100644 index 0000000000000000000000000000000000000000..96a180fff7d1120cde1cc0876a8bd0824e1ffd36 GIT binary patch literal 1056 zcmchV&raJw5XQ@)4}z}^*8Z_Kwr`wz>j9$*R8`gN?rgAXJJh>DM2Lp~DwS&#sW+sm zPk^Gn1brGNaT+zKx3*Y&nfcb=@0;Dd`EDk4FkntF_F>#%e8#W7JiPWNAW~I`RA$B? zIIXPF28BS!xz43PqcfI(OI@zCkpO6&Q@MatSg8`0NQsKw1@ytklTRN`q3XVERYclw zn6ksna-o4ZAQ^X346Q?%fRa+>oFz)=G|y9!7@jf4d1_HAlMS;xwf5%@TtuFRXy6Sr z7!}bLDjKZCx7hh=+&Au8A)YJ0o#FLtDoce%GT!yY{ zN7SzT0XpokHKaPIJ(3x;(@hbe8(}Da%hyiontp68p6}Nc5H#u$mG1{w*<22OCE=SS z`*rx=NqWR~lJ}Qao7?)^S8;v&VouZEZJpy{alcr0Z+GUz|AR)|o6lb>I{5sO|GGVi*&M?hZlb>JTAmF`4b~*X=?(I45TTOnP8(iv`AwW|5q^LO_q|s&c6!OPs z0c)_!vZd8-ZFB}t`qT}^G=ILxmbBek@68tD%4)R_@!&%w7$5+q=8^QaiXK1n33VMB zHHoF!m8fK!mbd|R!n~Ki&>pMdd8s{i&2y9&9!r7N0+t<@r`s`eAxUHALGyV7#~Y=! zUYlq66fITJj1Yh|7t}eY*ils}d9UM%s|l1U;$#&1aqy3~cNWF%X_mi6=!w?Gq#*1V zTOl1Y+g)@<1L6;dgc3!Kg0>JYCD|myBY@R~jURnTZ(N^3iOQy#s|u|}XTX^7 zLv~=>MSGY_{ZZolDO(Nop|tMt9#`9)1Z;>+V{WAKN`69G23_2QEQnoKrB=L3Pm{uK zv7vfovJ!FEI-Cwc=Hlt^m1bJ%izQOYPx7rE)>Qu&@Qa$p#P>KTnh1E9IAh-(F!K}i z2%qbMVa*H8SUgFU6A6+p+Ngm71DMz_PU=G#)&$n;LYW9+F6>>Te~-Za%wX?g9?5N2 zatmV5<}u}t?;#ZX2(%m?2KzB$7ZkBgChW(UM^d~mDT?$F7)Ml%p*qdPoJlNWGLu;D z<+XR}+ER0J@?lHLxC&R`!QKlOuME2tQgJC<*fH?QW2OK}`$o~0n+t8X{qm~rN?3E% z?bUP@OUkah%Kb%^3ana$WOXe?U6LxMkFxBinL_v*&{_f&QqnNn;=i0@xw++U_Yjqi d*GQiQQpH>WG&kr@0@fQvBi`5TCFG;U{0);2seAwc literal 0 HcmV?d00001 diff --git a/tests/vectors/unique-index/repull-after.cc b/tests/vectors/unique-index/repull-after.cc new file mode 100644 index 0000000000000000000000000000000000000000..3d2a3a1397d9bc454230a156771e01a5ee6bb01b GIT binary patch literal 1201 zcmchV%}(4f6oo@u4XUbZ`+A5-GHvXjjw&94AE_*O(R5J>O5}~NkMDO*d{2Mqw)7hGK8DVZ8w?Mltg_R;rknd5?a_obg^@4iQc6iW|!cX4M7{iI9 zWZ}>^MLnW&;kMCYgSDnp3#CJ92KA)dcxXoGhky92+p?sKwZV^rvH*ex3q;`-LFR3a zdUsBu&dDqAI2t#T|7>Sz@PV7RN5|dEJ$6z~&etv}@wW&6ayq@9&Z_6@U+-eB3a-!2 zu4a>!!~GmR*3Hc(r@4E{B3e6)AM<0RfVH^iYg4lLq=s%mVHmxv@wW2fp0@enMP%8{xExNrdR z0IOABf<8@We^@}<8!FaZJpTOs*mH5O+tO>$M;JOkXfXU5o&R|J=#GG=BA2mJnPdjD zm51Xf6)cx+{2P0X!|H8)^lEh9vcq?my*sbE=Zqb%ch9-Vh#TNhti{9Jgf9#%S} zxYSEBW6?k?cmgb8iIOSfB9l5nu*98;uCM~S7>Sf*wX&kiEl;$HV!9#!tXHRjqc9ch zLWMydtjGnO1>gW1R}AWZVaO7}Dy$S?g8Yy;%YhGa)(Pr4ZSS}xgdd(vHF`rw$-qj!tJ5O25U{H7D|Ui2K8{c@zC_q_g?s{+p?sKwZU)OWdS4_%q0pp4>F@U>pVG$ zmQHrSV}H;L|FfNi!B=iRot-a#?yZwDx?KB3iEm%M*-|W p@H|Ivb@RB%Y3@F-h-#m)d{!ZwudUhc_r~bw_SHgKy}R_z)nCc|I=cV> literal 0 HcmV?d00001 diff --git a/tests/vectors/unique-index/surrogate-groups-store.cc b/tests/vectors/unique-index/surrogate-groups-store.cc new file mode 100644 index 0000000000000000000000000000000000000000..f9774c857088f73e9cbca7fd76398ce0248183e4 GIT binary patch literal 1189 zcmc(dPfpuF7{x*2B6!D*BXv|U` zN||t?AqffNgayJd&Lj;DkBknfk)fhm=uq(hfJHGRLhG23BsM{k&~pdYS-+|B9}RrV z9h8@I6C}=fQKAiK5i%MH5|K#qm{68*6@f759kVtwOtc|jF=CZ8tW6D#lng>V!EYDK z-=4)XrT8RD@KSFM=d~UMU!b;mxB8m~BcZH>QliF7zoKW+bzVwZUNyDNzBj0Fy{$1L z>sf5(XRgkx4vusC84T1=DST^0VFArV)thacs5{`YXF02;DDYsV;n&9^7YOyHh}=$# zOl-DWPf3DVl2zfL)2(}N9Op6Mi!>YCyR$#{+MBXBSUAGQXD`0xXmm9iPX~*qPc`QS zSN;C+{lQ@8pGCP@rg-UANPugdkaV(qGJQTwcGpXLhwH=s$(Squ1Ni7}=iU2e!~QX1 J-@R@&d;n6NfQ$eD literal 0 HcmV?d00001 diff --git a/tests/vectors/unique-index/surrogate-store.cc b/tests/vectors/unique-index/surrogate-store.cc new file mode 100644 index 0000000000000000000000000000000000000000..4d0a1daa2799ca34f874b1ca12866607529aa12c GIT binary patch literal 1070 zcmc&y%TC-d6fIJJgls!Rj+1!e>=29Yx*%0IRaH6m^$<-S5Id+*g*5$y0i@23R#2%M z7OhyaKqGzvJ_7gzE)y7mrn|nZySdlL=V5#Ar)+X7h1>ww*s2D1+Z%trcxg5S4Oz_n zG>te>5c!00!aSiFXDRhH547^BPJKz0P`>0I01HBhgi;|RQK-ErqCYGcYuu*Hwi!6{AQsS-_xd9{-Wjk-Is0Uz^M?T9Y&+%ZX;KOR32^4iYiOh6C zW;8p!2PfX#$(pb*sOt7F+qpOR#Lc^%@%-g}_owVmo*iQ1<0p4Hot{narjw=b4>1=3 zXM6kKhBpV3(I4}2u}tyTtq>3EH6ih=T+*!*B04Dr~fXgsf`gl zf)OS~vZX<5>XmB+XD%OQ|xvXU6qs}`1J z4)(5`@Ycx#h}a)bX8+jErNIq1FAmU(Z@E~UEt=h<&gZK(w-e5; z5zQoeB|v44cu?MO=~HyMmM#OL#0rN@Th(wQAQj#Sqjc&5XC}|3QKu~@79)J!h}HoW zLRmu7(Ol2~1fo@@&L|Mz&>(W8vOxkeVYLaV@tmjx%}gc@dC4w1_&Y!wVfvLTdVj%Z zbN(OCt!eoap1YE<1D=P6Cs15pZo4`;zayAX_ZX-3l(AyDx@`5_T}D4{&p$mrZe&F} WpEoFL*Sg*HTKJ1@?OL}tCi@dSN>`Tv literal 0 HcmV?d00001 From 8adf014c66a30bf4dd7cd3d163418dd6de43f54d Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 10:42:09 +0000 Subject: [PATCH 18/93] CultMath 0.5.1: version, changelog (fbm), cultlib dependency pin Co-Authored-By: Claude Sonnet 5.5 --- packages/cultmath/src/CultMath/CultMath.csproj | 2 +- packages/cultmath/unity/org.gamecult.cultmath/CHANGELOG.md | 2 +- packages/cultmath/unity/org.gamecult.cultmath/package.json | 2 +- unity/org.gamecult.cultlib/README.md | 4 ++-- unity/org.gamecult.cultlib/package.json | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/cultmath/src/CultMath/CultMath.csproj b/packages/cultmath/src/CultMath/CultMath.csproj index ed99c004..d3846d4f 100644 --- a/packages/cultmath/src/CultMath/CultMath.csproj +++ b/packages/cultmath/src/CultMath/CultMath.csproj @@ -10,7 +10,7 @@ GameCult.Math MIT AND MPL-2.0 AND Apache-2.0 - 0.5.0 + 0.5.1 Clean-room C# math shaped for HLSL-style renderer code. GameCult README.md diff --git a/packages/cultmath/unity/org.gamecult.cultmath/CHANGELOG.md b/packages/cultmath/unity/org.gamecult.cultmath/CHANGELOG.md index 7edfa1b2..ebe21e7d 100644 --- a/packages/cultmath/unity/org.gamecult.cultmath/CHANGELOG.md +++ b/packages/cultmath/unity/org.gamecult.cultmath/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this package are documented in this file. Earlier releases (0.2.0-0.2.3) predate this package's changelog and are not backfilled here; see `docs/semver-policy.md` for why. -## [Unreleased] +## [0.5.1] - 2026-11-11 ### Added diff --git a/packages/cultmath/unity/org.gamecult.cultmath/package.json b/packages/cultmath/unity/org.gamecult.cultmath/package.json index 20ffe196..e06e6c89 100644 --- a/packages/cultmath/unity/org.gamecult.cultmath/package.json +++ b/packages/cultmath/unity/org.gamecult.cultmath/package.json @@ -1,7 +1,7 @@ { "name": "org.gamecult.cultmath", "displayName": "CultMath", - "version": "0.5.0", + "version": "0.5.1", "unity": "2021.3", "description": "Precompiled CultMath numeric primitives and matching HLSL semantics for GameCult Unity runtimes.", "license": "MIT AND MPL-2.0 AND Apache-2.0", diff --git a/unity/org.gamecult.cultlib/README.md b/unity/org.gamecult.cultlib/README.md index e118ac65..5fd83090 100644 --- a/unity/org.gamecult.cultlib/README.md +++ b/unity/org.gamecult.cultlib/README.md @@ -17,13 +17,13 @@ Install version `2.0.0` through Unity Package Manager with: https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v2.0.0 ``` -The package depends on `org.gamecult.cultmath` (exactly 0.5.0) and does not +The package depends on `org.gamecult.cultmath` (exactly 0.5.1) and does not carry its own `CultMath.dll`. Unity Package Manager resolves git packages only from the project manifest, so add CultMath beside CultLib in `Packages/manifest.json`: ```text -"org.gamecult.cultmath": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.5.0" +"org.gamecult.cultmath": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.5.1" ``` CultMesh chooses mapped memory for reachable same-machine bodies and advertised diff --git a/unity/org.gamecult.cultlib/package.json b/unity/org.gamecult.cultlib/package.json index 19e42b58..9276e03a 100644 --- a/unity/org.gamecult.cultlib/package.json +++ b/unity/org.gamecult.cultlib/package.json @@ -4,7 +4,7 @@ "version": "2.0.1", "unity": "2021.3", "dependencies": { - "org.gamecult.cultmath": "0.5.0" + "org.gamecult.cultmath": "0.5.1" }, "description": "CultCache, CultNet, and CultMesh managed runtime assemblies for Unity.", "license": "MIT", From 063835f67994928ccd2971b65f34a3d8389d462e Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 10:47:13 +0000 Subject: [PATCH 19/93] Unique-index vector: holder identity and replaced set are ordinal (rows 23-25) (#100) --- .../UniqueIndexVectorTests.cs | 16 ++++++++++++- tests/vectors/unique-index/README.md | 21 +++++++++++------- .../vectors/unique-index/holder-case-store.cc | Bin 0 -> 943 bytes tests/vectors/unique-index/refusals.msgpack | Bin 2676 -> 2993 bytes 4 files changed, 28 insertions(+), 9 deletions(-) create mode 100644 tests/vectors/unique-index/holder-case-store.cc diff --git a/tests/GameCult.Caching.Tests/UniqueIndexVectorTests.cs b/tests/GameCult.Caching.Tests/UniqueIndexVectorTests.cs index 44aef30d..ef5b0b67 100644 --- a/tests/GameCult.Caching.Tests/UniqueIndexVectorTests.cs +++ b/tests/GameCult.Caching.Tests/UniqueIndexVectorTests.cs @@ -351,6 +351,7 @@ namespace GameCult.Caching.Tests private static readonly Rec[] RepullBefore = Items_(("item:a1", "A"), ("item:b1", "C")); private static readonly Rec[] RepullAfter = Items_(("item:a1", "A"), ("item:b1", "B"), ("item:b2", "B"), ("item:c1", "A")); private static readonly Rec[] KeyCaseRecords = Items_(("a", "S"), ("z", "S"), ("B", "T"), ("C", "T")); + private static readonly Rec[] HolderCaseRecords = Items_(("a", "S"), ("A", "S")); private static readonly Rec[] KeyPunctuationRecords = Items_(("_", "S"), ("a", "S"), ("Z", "T"), ("b", "T")); private static readonly Rec[] SurrogateGroupRecords = Items_(("\uE000", "S"), ("\uE000b", "S"), ("\U0001F600", "T"), ("\U0001F600b", "T")); @@ -360,6 +361,7 @@ namespace GameCult.Caching.Tests ("repull-before.cc", RepullBefore), ("repull-after.cc", RepullAfter), ("alias-store.cc", AliasRecords), ("key-case-store.cc", KeyCaseRecords), ("key-punctuation-store.cc", KeyPunctuationRecords), ("surrogate-groups-store.cc", SurrogateGroupRecords), + ("holder-case-store.cc", HolderCaseRecords), }; private static readonly Refusal SurrogateRefusal = new("k\U0001F600", "k\uE000", Item, "Sku"); @@ -368,6 +370,7 @@ namespace GameCult.Caching.Tests private static readonly Refusal AliasRefusal = new("item:a", "item:b", Alias, "Sku"); private static readonly Refusal RepullRefusal = new("item:a1", "item:c1", Item, "Sku"); private static readonly Refusal KeyCaseRefusal = new("B", "C", Item, "Sku"); + private static readonly Refusal HolderCaseRefusal = new("A", "a", Item, "Sku"); private static readonly Refusal KeyPunctuationRefusal = new("Z", "b", Item, "Sku"); private static readonly Refusal SurrogateGroupRefusal = new("\U0001F600", "\U0001F600b", Item, "Sku"); @@ -402,6 +405,11 @@ namespace GameCult.Caching.Tests new("load-key-case", new[] { Open("key-case-store.cc") }, KeyCaseRefusal), new("load-key-punctuation", new[] { Open("key-punctuation-store.cc") }, KeyPunctuationRefusal), new("load-surrogate-groups", new[] { Open("surrogate-groups-store.cc") }, SurrogateGroupRefusal), + // Rows 23-24: two holders of one value that differ only by case are two holders; a case-folding dedupe would see one. + new("batch-holders-by-case", new[] { Open(), Commit(HolderCaseRecords) }, HolderCaseRefusal), + new("load-holders-by-case", new[] { Open("holder-case-store.cc") }, HolderCaseRefusal), + // Row 25: the held `a` is replaced only by the exact key `a`, never by `A`. + new("batch-replaced-by-case", new[] { Open(), Commit(Items_(("a", "S"))), Commit(Items_(("A", "S"))) }, HolderCaseRefusal), }; private static byte[] RefusalStoreBytes(Rec[] records) @@ -522,6 +530,7 @@ namespace GameCult.Caching.Tests var count = reader.ReadArrayHeader(); Assert.That(count, Is.EqualTo(Cases.Length)); var walked = 0; + var failed = new List(); for (var i = 0; i < count; i++) { Assert.That(reader.ReadArrayHeader(), Is.EqualTo(3)); @@ -582,6 +591,10 @@ namespace GameCult.Caching.Tests walked++; } + catch (AssertionException failure) + { + failed.Add($"{label}: {failure.Message}"); + } finally { cache?.Dispose(); @@ -589,7 +602,8 @@ namespace GameCult.Caching.Tests } } - Assert.That(walked, Is.EqualTo(22)); + Assert.That(failed, Is.Empty, "rows that failed"); + Assert.That(walked, Is.EqualTo(25)); } } } diff --git a/tests/vectors/unique-index/README.md b/tests/vectors/unique-index/README.md index ac4a40cd..3f6fd4e9 100644 --- a/tests/vectors/unique-index/README.md +++ b/tests/vectors/unique-index/README.md @@ -19,7 +19,7 @@ reviewed commit. Other runtimes are held to the same canonical text and the same `" "` and `item:d` holds Sku `U+0085`. Written through a role Sku. - `blank-store.cc`: the same, only `item:c` and `item:d`. - `pair-store.cc`, `surrogate-store.cc`, `types-store.cc`, `repull-before.cc`, `repull-after.cc`, `alias-store.cc`, - `key-case-store.cc`, `key-punctuation-store.cc`, `surrogate-groups-store.cc`: single-file stores written like + `key-case-store.cc`, `key-punctuation-store.cc`, `surrogate-groups-store.cc`, `holder-case-store.cc`: single-file stores written like `duplicate-store.cc`, through roles, for the rows of `refusals.msgpack` that open or pull them. Schemas `tests.unique_item`, `tests.uq_pair` (Sku and Alt both unique), `tests.uq_alias` (Sku and `alt` both unique), `tests.uq_alpha` and `tests.uq_beta`, each slots `[0: Sku string, 1: second string]`, schema version the name plus @@ -51,19 +51,24 @@ last succeeds and the last refuses with exactly the RULE text built from those f byte-identical. Schemas are those of the store files above; `second` is `Category` (plain) except in `tests.uq_pair` and `tests.uq_alias`, where it is `Alt` (unique, alias `Alt` and `alt` respectively). -The 13 rows of the spec are a floor; rows 14-22 extend it with orderings that a code-point, case-insensitive or +The 13 rows of the spec are a floor; rows 14-25 extend it with orderings that a code-point, case-insensitive or culture compare would flip. Rows, in file order: 1 batch-later-value-staged-first, 2 batch-surrogate-keys, 3 batch-interleaved-types, 4 batch-two-aliases, 5 held-then-landing, 6 held-in-the-least-group, 7 swap-off-a-held-value, 8 load-two-aliases, 9 load-surrogate-keys, 10 load-interleaved-types, 11 repull-held-pair, 12 load-repull-after, 13 commit-onto-a-loaded-holder, 14 batch-key-case (`B` before `a`), 15 batch-key-punctuation (`Z` before `_` before `a`), 16 batch-key-case-in-a-group, 17 batch-surrogate-groups (U+1F600 before U+E000, the reverse of code-point order), 18 batch-alias-case (alias `Sku` before `alt`), 19 load-alias-case, 20 load-key-case, 21 load-key-punctuation, 22 -load-surrogate-groups. Rows 19-22 open a store holding the records of rows 18, 14, 15 and 17, so a runtime with no batch +load-surrogate-groups, 23 batch-holders-by-case (`a` and `A` holding one value: `A` before `a`), 24 load-holders-by-case, 25 batch-replaced-by-case (held `a`, then a commit of `A` on its value: `A` does not replace `a`). +Rows 19-22 and 24 open a store holding the records of rows 18, 14, 15, 17 and 23, so a runtime with no batch commit still walks alias and cross-group key order. -A runtime performs every row its API can perform and asserts the count it walked: +A runtime performs every row its API can perform and asserts the count it walks. These are the counts a runtime must +assert, not a statement of which runtimes walk them today: -- C# and Rust: all 22. -- Python: 21, every row but 3 (`put_envelopes` takes one type). -- TypeScript: 11, rows 5, 8, 9, 10, 11, 12, 13, 19, 20, 21 and 22 (no batch commit, so no row 1-4, 6, 7 or 14-18; - `variants:idea:unique-index-runtimes.ts-batch-commit`). +- C# and Rust: all 25. +- Python: 24, every row but 3 (`put_envelopes` takes one type). +- TypeScript: 12, the rows that only open or pull a store: 5, 8, 9, 10, 11, 12, 13, 19, 20, 21, 22 and 24 (no batch commit, so no + row 1-4, 6, 7, 14-18, 23 or 25; `variants:idea:unique-index-runtimes.ts-batch-commit`). + +Not pinned: the schema-name tiebreak. Record keys are unique across a whole cache, so two types never hold the same key and +two groups can never tie on alias and key pair; the final `schema` comparison cannot decide a refusal. diff --git a/tests/vectors/unique-index/holder-case-store.cc b/tests/vectors/unique-index/holder-case-store.cc new file mode 100644 index 0000000000000000000000000000000000000000..ed8f9a2ae3397342085ff4eacb7386ea87d2958d GIT binary patch literal 943 zcmcgqy>8nu5RQSoNVL`wC`zJ5ZAOP~9jc;BQ4}ZgXbX|#R-}p=MIItuQZhet zO;HbMT-bMLu)$iSIh3ZWaJgs6zJu delta 12 Tcmdle{zZiO4ujZ6=0Yw29n=H$ From 3cb5a1753ad7e2f6618bbb14222ed2f36225b82a Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 11:06:16 +0000 Subject: [PATCH 20/93] CultMath 0.5.1: Unity plugin DLL and pdb rebuilt on Windows at 0.5.1 Co-Authored-By: Claude Sonnet 5.5 --- .../Runtime/Plugins/CultMath.dll | Bin 349696 -> 350208 bytes .../Runtime/Plugins/CultMath.pdb | Bin 163436 -> 163584 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.dll b/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.dll index 205d3f9bbddacaee25e58245eb3fdf2f758576b2..7757c254ecbe2eb1730a66e4658b088a867a9e5f 100644 GIT binary patch literal 350208 zcmeFaYqVY0bslzbUiSsui-SvE0DJ)iAKJPXq$tQ@7+Db@$@GJkNr$G%i_6FoDS;OC zAZ5U>2!aGDu{t74iXVBnaVon?-3+U(g4F}&h7!3x4vHbt#mj{Et zC0qP=LCeL(b%8fe{p{x8Z?1je&cAi&Enm3viFZAJ<@Eb6zxb_}pLyTucRcgL3om~A z>1QvUzWg08oPPd=(_i)2H=Tapi|@R2|B)l>cekOx@!`SXEr&*fqhI>gPjzg+IXH8A z%pe)1tvwY}C=*1O6^ zS2_I9QzCzBFk6|eHm4-kY^|AzxUEChvd)&NwwxB5=1v%Huz96wKCI2hjz}Qs*7~u{ z*}7I%W=ER4q+xq&b+*ac(b?8)tC2%?eRgzqR4dIr*qb&YYOj&p?rqOzvfH3_G8?1* zN~x>A8f_n2n;lcv?d^Lc-c0e?Hp5PuU9sN2N#cjV&9jXwP&vCviMMx-?MT1&&~li- z%?2DMu(9PbaZGYGJG|wRpe>Tk{cUM%M|8G&@eM!p(#tPD^a&a8F|ys96^=(QZXcf@ z)7{>Q+40%&gV~AMiGzI(=zU_=+%LuU$=S)x$pc@jQ$>pF=du6v#ReAJw``7Qw;XKW z3Te%&q~)GRCDh5MXSW{gKM8eR1FW)T16*-}-f8h46&)v~`d~4Vqy(hLt8!9+~5IJ88GmcDvGUSKIB{Lw^p3 z=^^PY4$}4*9m}pAk5e9xlV0Nr8;8BdF&js{#t9pz(x~TwRqn5J`{X<(+>*NWV;gcL zYUQv@F0CAyjak{8O<38IbBaPo+mpd;V)j!8{1zSOKiJLhU&IM! z!Tc`QDL=pUn7#+|o3)d2oa+&Jhk79fw=P zo-fw4`@1u zpL+h>@91$ZQy8~+GZBAuB&UH^{*QKN1stH~zJzP|Xy9$pQJg=A(;s@0wukfp7uz=s zN7v-mYdjoZTayiZhXc7V4OWC_bapyAe*ebavEfzNtnb}4yb7!Jy^YI14v`N+A3@mrMaL(+^aCl`~^zS~)Xge<*Vl=4`_$YyqlD&=KND1E-7`)vZyge{@#v43C zgO7WPP5?PN6`$#w6q%h>k^3QU_?j)x_L5vK+A)^1Htua_qwiRk^Y_`U(T`y(LxFpp zshB;&Fm!L^G`_d{vCBU#*=74DaC1jctBu_JtnWTLWR@tZ(Y$_I+8&YQ%gJdlcn!SKq-3kvmAad#0TfaTO09w~t}o2v}CJw6TALo3d~1 zD(_;2CUP^TlMi=iT9F2HM(@tFA`R%0HfTkzF|cIG+pW?BkLnIgZQDCA4Oom3ZGmw| z1}9HeE@%q0kyX1yrZLK*j7{LKVzzD;*YShMsDW2lk zb-JT09bp$NGa+Dv63Tm6sG>ySwuc(F>LJ}NNJVIH2pC~e!cJMTLM3X{DuF3-LMpnW zUJ(*NSpYT#$?|}ODoPX%aspE*@$RTsgcKU|(8pDys)vkAca&k=fRKJhSR`7&Dii?= zCATq_CSNbNG4rr0?4E1pVGSO^?4PR5;WRkC)8On_8k}$%oNy4_G`FY4zH$!-!9AP? zH=`=&1gu;Dr@`5=AT10_81*nX;UKt$4b$S`Ah?Iq;1n1rr#Im=IN>z7DX98Qz$g~L zz2K%cJc#Dup7MD(4Q{4Inl*Lj$}~7l)8OoPFXuiu;WRkmAh?ea1ovh`6aEny>ZB5-hL>io?L2$A{b2VG&;o%^-htuF@R4FH5 z5q8JX~tP z?wu%h+9#?0J5dF0Je=NmIMs)M-b1g>tQ!xf-NQc)a((!h7X(NR-xl3?IPLB@^%T^- zThY1W9+tyrP(2$*9v14x!>OLQEuG~WU}yC3PH7(2Z0FCen)3+e#SQU7Zo+AB!f9}J zEDcUL4Nf=+Zegoi0;a{oL2wVJ!P&7uIlT#|!3n3qO+l4&0!Fa_?gcls;cS_Qd&=kG zG`N`-Y1Y)8D$?LIO@p)Fy`1~tgwx=JgWx_!5ZuE-a1W=!&8SKX0V@~4X>fKdRZbWb zVQ|7ha0^=z7DX98Qz$g~Lz2FuwJc#Dup7MD(4bG1B z(&D`dr@;xQ!7Wnhw>8~(ICVz}(_-mtAE3I6Rg}_*vfVkKqEVio7Emhc+r3ynP>$8-jKjXtqEAZ0_ z9P#_T&Zpm{=}7g$}m4^TAkuI34SA7$I@+>n>V{5A|DJ#1c^B`Gpx{LU2VgI8vOX924S_gY5^V zgRjwSNE%01|CCt!bsTT4_P?dte_L;#QGY_>yhqw~UB`R7%KN&?3#`yqmT%~xiv=E9 zkp|3FUC*HvX~4A52CYZ~W`;IsMP90L#8H-Y6>LI?Y7vJ5?MpRfDEL~-SEbM(G41-WH;n(5+{|FdWFQw18d?ahJf;R zuT;E;a`XWxZgUU;+TRE;jDit45(-x|`mxyWSn9)?K=zEZyb6*mMa2(<=k&Jpv^P zxAUQ$8@LJNJyYdwN=ipEcbK9hWH$|!UV}rx^j4uvNdaK%$1s$rM(t3cw%q!20`Of8s#(7U4^ z5}J~wabaq4!4l@z%Tl8%zm!FHl$9(3fnEYj^r}%y*w;&1WVs?VJs}V%p@%6cxkl5{ zoTyQl6S`YY!V&TAFtrh(d!Uy9%F;tN1#`{=ff9um%sKCFN=ipEcbK9h-W~Nyua2%> z{t1L})u_rZ%FRR{?z(d*;(_0m1i3$t)OsGc^BY82|FyXg(P>)l~$X{0;KDvbmJ zy-ZW@7-17jQ&KWVAmA`Rl9wk%n{#bux|c{b(B9kSu(IYb6!1Z~;^uH^FC%Zl+SeRT zx#^v9GxaGq;gp+j%1t=s=8&Y^gi~(9DK}x1RnD0Z8{7qO!0o*QZV#v2W)yN0M!5h^ zx#^v9b4Y@m(~oeTvr4I8-{U)4p6Hd7a8+VysDyqHqY5o^n``7`F5(lw2L+0UBExk?cQaS2SUHj(wtbZ~i z345bJt%p&shTX%aHVmF}TZ}L`;gp+j%1t=s=CG&zCY*8;PPqxE+yKJ)Y?}R*tV$}q zQ*OcmH$`!d2L1MM&~Fb1+#WXWaPq2Ydn~D%>VAVsU_jPWwS-f(7O9$FKDenZc_z#< z<+d1MaEq}3PPqa28L~8y>d+<PaC_Le z!@jC%Flpdsef0rR57kmyFs}3_oT?>kYD=EIcghXFrQ@oRf{RUmT*=3PsanFRTEeMX zQlyz8oN}AMa2N=qEHLHRZ1S0sJkP^D?s*t33xcQI9OGWUeQ?4lH{q0WaH^JaQnef(Q(FZmoN^ORxe2G-)Sq%&_rg3APPqxA zEZ%GaA7@iFr{>{azvtl|_dJZ2kefr&>$mqNoN^O3?lQkL#*&*ExM8}_^QCIxvLI80 zP3=-S>QLQpcsUG+GE5%fRINn{y$PGzDiwrNZo(+5;2aa1SqtER+j|>#m`F7ZPGfMh zarFUHwPqBmC5&A+Z@! z`t9MAo8Bom;gp-XOSuWB+=NqZ!YMbqmvUS0!>kcbxe24Bw=Bs9Q#;1uHpMD!HfjH~ z={Z?(fPWIUTXBGY!Igg&RE(7|HwJ8xl|MHI3}X(GW}WIN1V0x=irlbI{^Ts5ll%eZv6 zUKK(DOZ5727+%?s|8aS{mt&vPvU?KrhqOJ4V>V(v+0U z5ePWUkL2<&X0y%L%R|XLtbF|?kKRaGa~KN3;0W7}O5{yA>WtDR##Kvs_9B_N@fZM|v#t60QCiwpjaU7~aiuq5Q@d1-I)vlu z1Hvjy9{EzWjFIM+u&FI`m;lR^+l-1dCeLP5m8p5S$2|}CxaVOg2)WI40i1HvJLM*v za&t&}IX5}oNh#%~cgjuJxXb)fir}Yeb*RpfUJeH_RZBQkOW4#dm7@-=9Jt}=o0kKt zYT&$4pw`2IS`VAr!tLRd+l)eKgi$VlQ*L^v-0%yJCHfIgxe2G-gi~$+3%CiV+=NqZ z!YHe(QIwrY7Qg|w_YSx{Y~11GRny>?UbxxhWk3!_Fgrb*s--tf!@d$WwPo@$ILnmV z6okPo#sWCy2HLkFq=B3D z)dxg9RLfxx#+BZLQ?-OmZJ9LQJLQJo(s9*DZC}+P99Qx&V5*id$}km#Q?;Z>GetP% zHi02GVUz`?9Ggu(Q$eY1IOQgsauYW0GToITxZhBR>VCt^ zv3l57!l_!qC>MB}+G-d8EEpxi#=W$!>d^KTZaB29f8A@{R4w^XR-Mrty7z_)U{hPT zy?4OvVdD<_s;0qd3~ts}2DGrDS~FSzr)ueKYOB6l&con@5n}{-TaW++R zY98+Odmip_&%ag_U#b=^3o=F6)Gn2y4%PjJ zm&1T4!{iZ8)mo&`o3N>^Qb9Q7CXBKQ&M~o>wEzydy|;0PiB!|zGzK>tS06A{Yeu13 z!YCKOrnV%~{5Z=(Z}|B+wY0D5P@N+=8suZZR4rkY3*@AkB2jR>lG}8I!3n3_gi%)B z)SS*K4_m*(M5<{pY2ap)#s@@wft*w=6JnWKDn}i{aitsvL>bBCD0p<8Hzv_O99v)7)X&P`FYsl^4Am<(qxIK(|l{LZ~5}P5V-yTl6>78;D zPPv)8l$&tMO*rKyoN}{!DYx}L%o^d8n=ndxf2VwhWJjylx1>=%L~@*aM`dsKO(#wa z4~7S~4G;DYPD-KQ9vKb(nS8@QzEm=Jc-iQwW0x*EXlkpOD70!{fU<>wB-;8c&ULBtM($21Yt6^hss(h{W{m>x8zB~T-A_co3l8jTJb z`7X@fVXGdIs@tNH^J9+=Al()QeXz$R?Ps~n;#1pdo%^q5o3q=Sua)-Z>!jG;oZTkSZ4&j|pWj@YZ60iIwL!o9;q9Zxw`Z%3eAZ@t zHs)rHo5`s8Z)GO$Y@gcMzI*R3>EPYk!AI_!9XmWbb&rHR#U9`N#O!X_yL)!`=-fYH zthL#MQKqw%+3K;wJF`>G*F(@-Ky+l(d|pC#BF=B0zIpr3o$Y(}J}8mzA)J9Yl54ASh*o%6RG8Voj9AK6?#_rT`H*}FG4Zoj!Gn6JF}x1~Fh$cOG4!c82H z?>`}acWiF#%PCRzDU%B^~borHSmv^NHyV~Vad2DuC^1#N&4)&$DyEX^9s?LF@ zX0Mc9ymI!+*{O5?iTK>f+!g8v?~$Nvl*HWGM#03rQ|XUC@zDSN|GfON8I1#@o1b{- zm&9midvEV8B7CnEepbBBcEVYCOeC~5s6K0|yK1X?PgL*C_C)nhESiViggVSAbZWM> zxzYT*q<#BL?8f=Gq2@R<&$dS5@eA;{Yj)S{%xvq}(OEawY0LuvW`9@A&Q5l=XM0a@ z7Totom<4;MyD2ccYv=rr{NIw)joDqAs+}hu`t!2Cvwhzl&L#Kh=}3}y9|!9_PtNX> zblf+)Z*=ZAXEVKyPY2&1=OFoF*=%QBE}3uK8f+a}X{2rak>*h;AJZfK@ap8jGn?bH zw{4D37JPM5MxQfuU9Ya=bALy!hWyUayJRb0x|rR(zPYj?SKIxqNqe>3nzohuFKmrh zNhp_sRgxYYSzCGV6>`;+$=>`oNa}cV?d*}wwd1R6v+;`3N#()u+Q#a-UavP-&;7Nj zd~0et_-n!`cQJBb@~V4A_np~X71gV^+&nH~ONFX;#qevp3;VG{&EFHc^}BYS{GUJl zH%7nw`jvyj)5&A27hjslH@1#Qu43}ZOXyQekI|6u{b|A$LD@^b9{XNy=}*S^7RMP1MTy8ASdyG-1Ev@ zl$m&F$}pEOb55;n4W<%%wZ!hG%Kio?!1&y+Z;hrp(^sC-yFg+3S=^UR2fruhTe(rk z{nnq9G>h1WzFV5MrtMTeyfu)SR)Wjj%Ve)vlf+2lY|>sTtKEZ(M`jZ_oF=o$%DMkx z?XKDH-uIDz|1W>rpXi4=cs-W_e2DWjIreQG9}x2Ln|%DLa5iKG$N z|JLHm=P?l5D+n-yu0A{%JT+5%wlY2U8xQ>@90V=z&J%0R;}U3l+>1LKYrxgLcgZn7 zm8-NK^ezkAYdhQPdwVmuqk)RQ#oV&eK~~nyK}!GX^^L&nLLHG$|5Y)%f= z%CuVJx$@xR;kD!YavGA+kRu5L(VBL%`)M8+({n#DJ$`X}ymwjdMaOiOIUy5V2K)Zm zWNR>+3c+mLF^uGTHEp|wu#ZXpCf(pq&wXJF24iQSG2{|sF(jd5aL&d%uljTHMrL$P zo|EvU$osIFT)Q6|`BJ3ZjgEKa>uo^w>up+*2H0tXR-^$Q+MpF_P*;2-PAm8(o-dim!|T|Nu=%ecC&O;4SE@tYMrb*-(u_@wO1xC{rM zmRL7StkLDaUpz;{=D)+K_t@a2ov)A&1gbR&MwkD4Z}tyTvw!Z*zL1*zOK;Y}UB{2NBl7ro*dXPPPlgS0`uL_aAZw4y1#EQf0dbV6_iAh=*Urgi z_YKng$JzWk>|=(0CHLj)jWCnve4U~46i@Rv5|#sk-!>f%8tLZ_=E&LU==l8`d&h=X zVWgifg<1XS(jTDV`rc7d15133RO7j?k)Dxnk^VYHaecV^I0If9UWL^qwttk3BjwsB zG*W~8z~G`cxEL55c!Pt$;AwC0Gz~uPHM}IR{h*H`UTXjFzi%?^nGE@F1AL|zWu%`7 zb%D?j8R9;t7Y+?H?34Db!dQpuSZH9ZPY*wcF=C#Z1I*lG!w;H1ZR&l4R6ZIi-yoGz zzd?#EzCrqX7|b2~+Xy;?G*!*Iyq4Jg#moPf9Ofs=$3}sFLf#S`(vOXTiF?h{Lf*({ zIM;U{m4C^CssJ}SQRG~RCOtW7MH+x=gH{SRV2)sEf&rVJYh&xwlsXVjPcg<0c`9QC zT4QA|#ui!rJ`a#DuN<35S}H(2`heq&fS!lhwn{q>4+I_t7gcPsl0Zq?jXdgv2-Bsx z>1V#qyx-OHydxH4#`EpxH?1Er=I+r(7IGxLl7|zr3`ltKWw4AwlK7M#4 zwW0i9-u~i_V6b=V@G9K&^U`qCb2MD*bMzNS#aWI~+>#IWP6!*kak_>xwo2pFCX4VuP5FO*Au1s-Qfox*5O6T>&ZRC51Mbe z#F)2TV&uMRsPFDi=ru-4y~bdRYs?v(Om}cPMR4|l>Ki!(_Vm7NGPod<8UI~=#c=Tc zljX7l7rX30P`fAJxQ3Rwi_qivTs02!v7QAQo%Yh`u1sS#;*!Env!4h@M6OW)^ctlV zX@HG3Xhj-et_@m|vo9=J@+wrC;89(e)V94aX~1HPXbX%xGB|m%a(z^wjjY-wGL2Cd zWo!z_ynjcAZAhj-MMh&IE6ThvVBB@k7$udJN#c^(ovy8kJ>sEXd1$D<2fGUI%7cI^ zjXW$A{xJdg>xowCaN#MQ=R=-wixPG!AS7UfVO%w8i-$Gp-)BUz2W7jKV9MV`1Mx;*INJ{a%aWF3ot?*#)+y zaPl@)ecI+;IC*Haa8LKcby#U;xTo82ZF4W&TYb2<_QQ2pX=b>$w&B|5kNcs(Gpfn! zUQ#~WniSLkh7^E3?tp&KVJ~R=TC)W0(3c9(29+OERZItbrl*t-x=yS<=*o{oQ!H)O z$@7x4(K=sQ%3Ey33AY^P-KXJGv7^za;y#VD(Vv$q8iiXL^X{ieo}wK``);+Qs6)G# zuFrNoIMk)cUfXKV**`va`?%$`ZAp={dCA`DmJ})6YByM3`Wj91b~)Vf($|Qz2`_!S z=W+QtT;BQ0*+`$qvBmTF-{$l9BOESfpgGKUzR$?Rx4rWP+b16C-uZ%vd(}H%iUQo| zN$N>S&$F`kp3&8JV8hm*lh#$)y>I0V&ck~X*<3%*f6lkxMtgb0Mqqu!)`~QMLmRYG zdH|RQVQGQ^n{by3gKH?-f#V5QjA2He%2)x8fI8wdOJj@NPLwW>s^ZvGj)V$Ok3PUq zfrsPhJj}LL8UZ~H3le%5d__aGv(opY^?UD%9oKzW`68IXwP!gTc$;6y?cvnhjIQ*# z30PTR^YGnU0K*}KEz$xw@HW4y-vm6IdYe(j?PHjCIY@Mrg8K?>x^djd19y8g&*kyP zanqeQ|9^Dc{KtCX(n7Bm*S}Uy26`3Kf^D*>cf5O9UP9~cwMTJ*kyTyXdki3c-q+^w zHQhfq{2+FDwR?~|BVKem2=i~ZK)NRoyo(oh%uAupi;(VpoWJs~?QN20mr-^vf3IBl zuRJKX6d#al^Y#N{eV;Fj>u1B}8EIV~HqXjtV`X}2x^m?Z4hy*(f$Y&$lzLNvyAkEO zin6=U(0KfP;5NMJV;COkC2Z-4qsSbo0Ske2SZKX~bHfAG`iM0nQEiP>*|@Wvw}oUv6__vpyq z{@U}H3cRZNuEOw&+@G!EcIBP29bUbp<#ST<%*S5~;Uv$I@RAMO1vnepS@LY>P*dj&?f6mVhJi!_n_KdCjMfPK@L$NJp zW)NU0S>>MZ)C!yc*ZInwr5-9p;gzSNn?)2|#2)7_5=7pAQMEh#EWYQGHIQP4j7 zy3X=p*iq{vug3mt*JawMQyW0-`S9GDwldVTRnpc~j762-o1Gp$Bs)E9aC-FFxUa`K z>63MvIzDCh^{8p9r>(0Vi~6~q9(ksAVr0)LX@83@gVNbzMU|R$zgCw)!Gk!$iA6go z_Di@+0-v@$ul^G5smm|nWl)@xKB5;5&a@qr_Hms`5V{jnOy&1=)6=|N&ah|Y_jOam zCb@o^CT+sgJg&#Qe%`LvPq`}V^%GlMKfe-Z2K{{<6wEKhHk-lk>%Y$Bv`W4 zdpEoQU=uvpR34EOXx|oSz+#MO3yeE5IC-)XS%EgPYM00~Mp=}xDIoLy9T~PEnF19V zjg71*^TvRYAR42jvS+)Txn%0xr~nNg0UD|gXpeL#c%3St2n3-BSSb9k3qjY~VFS*a zHXgUUhbnQ|DcDwo1dK3@t43{HnBs1W*`Pbh5|>H|_Xs^;gc8aF7OE&wnCHl@x3DTb zq`L*Flo%WWMp%@fshwLoY@rf0YIA~aA}6GxJL(l70h9$`Q;;kVSg4{z;UFh4g%a3Ppqp}*tagmL*JL%o@hEST=3xyUdAs!F zuy#L()8Oo}=05T!oCYTx1UJoLT09&C_i!5AjH;v(uyO&M24}~Dv@k4T)WhI}gWwi6 zOpAwu;2utcQ(&N+-h|WOgwx=rpz1dPqgVj0;g{=-Es`YRX z+{0;bMhcYEn{XPOa2nhcRQ)Dk6bs;9a0?h7MDuV@`8=EkXUBSJ@!o{f;DpoQ7OC{x zn!Z&5zuzfvjJbKZY$w#!;|VfEf-6iOqU24}|*FZ3pyDkmHSx3Hmd4+p_LoCasd0_F53 zoCYVH1~&y&S_l}$0=O64)P}QV9_}fhhtuF@TBKQ1_aZM1PSZ3v``ydA4^B7@PB;ke zV+6rH90d1p8r+Pkv=Fdz0h|VB$5Q2lK@kQg90a$p)j>qH9u9(gI1SE7fpU5iPJP!FfUDKHIAI1NrX4Q>j;juB3S6Sm<0s9Gnb_lwu%UuSbG<#eGH z0r_{O#=D=#KhUcC*W_OW!oO^TeUAbiyFr6A1B3Khp|k|Vjs;Yx3rMI7 zs8AP>kna7m{hJW-&&lGSmK*Hd%%02QJ}}D$_H%3e)&}Rqcz%taj!rd-vhGh?WbxlO zU`;sIBxL?v-#_glTt81w2Kcw}FI-&+TXH80@(TZG@SltRJFrGk)7d;L%GP-VH}8=8 z22XX(JEeYj^zc-yhw}ep@_$4|@82Zce|1RGt z8c(g2MV9c<)Rq+@JiIU`%Q6u*VJ@~!vd9vez2hxylXJYaZE}vcv47t2_O{76-qALh zqg$QAdHEu{gHdj0AI96Q>g??*&`zxD@^7w%D?R5u+ITu2ugkw^#($^lPf0~qVai|T zVF{@GVV?dwUGk@S-M<4P>x0|1pkt{G<^SDUP+8qG*t>gp6(QF5?j2r55M3CGFuE`l zaq5Mk;)OZTt(O@++{IlN1an>1*$(zjizo!f5>dp(7Gbefb#BS%uKaT`!*VUq9;p`n zLqnNFu)iV;ch<3{Q&#TCG55Td@7D4?Qqo)2kA9r}k%gmRl?_N`gBX`+^}93~X~$(% zC=5qh$_~s&TFMTbMq2LE^{=$9omc13*%q@dco(+|*^AZ_B1=Y1i0qgU*)bs(`$9Ue z&Y`m{W)y-2r-+U%0%MDqtyxdVffph>CPa2j$U$F7=hZoMw#AG>uo@LYuti{O5wkVx z36aI7mJ!)8A+lpap6(0jygG-@wwO@}mZ~B;wg`+ZVzy>IA+i$HgvgEwksTBA*1nL= zt8?gViy4Jr9V?<^i@?|-W^49`ezIbTswu&qNx`0Jkrk>RIV7jz&$jNxvp5xdGo>H_ zs7Nay&~XsSQUj971f&R zd1*3`Wr6x%FK=A%>QcuBCzK6Nt1fU=4ga*up^MTko26*y%O$j}TPt1RR()I74zzXc z1M2KC{stlJ0~g7{feo$?x+u2a2N$JXYRf)2UoN}t_d(&d4{crh(AKpNsIw3K z`5~)mood;0l4Z~7ZQaYH?L4s{mohv1j_I>932?#CHLyBAuozaQp`yClsEg(Y*WP-S zSXJ8-stsOi>1~-GE=s$Cw-s%^{NvkxZrjzoR1edx1FW+Tg$>tU`2!Hn@eiW!%m7#P zJ~aHG^@2@uV`m9xlib)@PqeAO?)S9b+uh#Y+hL1)yT8bLyASYi;Gi-`9K=SR1^72K z@^>}*-{qf@`@8ZtG&)e_&Q9kTpv7X zwDsL+l^bnYHH=%ivHrY5NF4my;nv|p{Z<#T1k`w3$H$**#)RO?abW71Z6uiZrv!hG z?8)lm_j2<-ZeGBKHn^vn4CMY-{}YFqVEw;R=BMB4w(Y>~rnxqf$f{%L)K$k?kp@WC z2CYZ~)N6xQqyZh#2CY2OjXnaD<-pistZMDC0_|_X%250feu1&F7h_N_Fz%Frf%+^*GzyoQnYEjR58715g}t z5YYZc9>!4?<+c+ZCmlYNwZH6YKTD?=*OV-Ehbev-W~UJ>)JwqhhH=%Xl?Br}E^vMI zRRc@(`nY|)*bDU%SfW>rLcMf1y@C%+MxkE12YLyZ-cXj03scJj-BFhAG6TV!^MENSgbD{!3#K6S?x=@^retYc zm|9%0g!%Qd)TqiYWzijFC5u3ym%tLeYSa?;^^z7@t_V#}2n0&#VM5j5WBY{9K)6_dg z*u>J5l*|zbILwda<*Cu;T$`D0flv*!&l5SUtT_w?d?QzJbGWpZkvC!OYYwN}^iH{% z`jne+%1t=sCY*9}NK$UXDL3Jin=r~M=S+wV?gBXA_TB-vhf{7d3b_fRTmYxs^iH`s zBtg#UM>ypsoN^O3ZuwbTPmcP-i_{UfdkBFoe;(pnG5!csjtuKYcvN~gRSO~Qlc*jz zOXa9TD+l`U^A0D9=d=D5m>o~&l!pVg9!9+yb`PiA6c~&vy$Pq>gi~(9#vLY7iTI|l z8)JA><4QR!Q?-OswH71H6k$_ajS_%xPMM!S(=Y9-#%m`HB-^C%0Z|XtQcj@ObcSj@ z9AwJF0k?-yucQ&?um_{$;gp-+#vLY7BL$NNZk9;ThkB?ME(bHlXqsqC518xr+cQ|>~v^|#E;|d<-e#2*&^kYD=EIcghXFrQ@oRf{RUmT*=3P zsanFRTEeMXQlyz8oN}AMa2N=qEHLHRZ1S0sJkP^D?s*t33xcQI9OGWUeQ?4lH{q0< zuyL2^t`x!jhB{RD8(xmp!@d$u)e=Uzz}wVT!vJ8xC=oX9rF~V0wy$u*p>6%^PGhNB z@}aCcqd9C}2?XQnVN+YUJsfa**to;Ks%dZhwxbP0j+-}^&U>uQckLt<6~;8;Dl3d!YMc5l$-if zZtGr{XTm8rVU)$2P2l5fs^-)@-0Sx|+~b~y(GqfVNP7MD-h@+b!p2?Zm&RCfGXpnF z_j$flEnF64im<6&Dn}iv`wcIL0a1p@Bb=(WNTD}jQ(L8iaLP>>Wfh!bVl!(29B_MY z;|>$4rom|pZZ@tyV5-)PLbZfZE`UvKNu>F4mWAH%^K)uxU)7;HM{+dC$AGC?!YCKW zNi#*F;CLms=?H@pPPqxAth}i?ol_pReus%v(_qrT%_fZxi24FKsaht)GPP8WI)vj& zIShz0ltVaGOE^_)Jr0ALf(3BOP4AQ&fIkAt@ne3~{S-YsoO07N;5OEf+rvT5Jsfa* z81*V^ggGQOLrT9roO07UwTCt!YMall=Lo3@0G9n zH{Y<+&Z+*t`KG{)b^h+HNA(v7I=0nndXBfwmVP$9G2k2P{PnGPS;od`$Wmu?e{ntm zoy&cI2RGm<6agKH$iq0w56HQXK%hk7U}0>h#Jii4(vi#^rsxRWElAZ7gG0ddR-sHu0buLLFqEi9)tXgmpgZc7 z5&|egz?3W{?B@h~OZBQzsF&`6UIL~!l;z{X)P{=gC`)&lfnd&gz?2k1g@dUDQxJN0 z)I&m3vNSGCEiPEX{CZhxROOel=#H|IMIg{iV2NHeY6<&#N!vT7*<^YuB?gCpDJi)| z)6$%%QJ53DTTa3e@$N9S5urO&S9%GcEC8E=Ip={uiNXu!oOd@Rr6ZX;OwkeVj(Vk6 zN7r5V!nkTw<(F~kZoMjm1eWOaar=7d9_WQxI3H9`n?^MX_0rw+hTZk19*fT>!z zgyTwY!lrhq9CZlC)dz%Cm^|{OY8fNVEn!nz<}d-4DYqFFX-uBYrYckOaF2T)?s3n< zP!MvP=>j+ zT`ET%S~+mT(Kjy#R@K0Hqd={P1GOGDwT0WmDYqGg(g>qm0H@saPPyS19!vBioN^OR zxe2G-02Xi)PPqxE+=Nk9S)(XBlPrJ(Ztop%d)TEJkfn)KhxRN0$#o(bFwGQUXb#m9Mp?lG(mCbf zfZM~y9rjgCgGmE7>#GlldZ?De9*ir!38!iao7ysIym!hCzop}^raC`57+r!2k_Ek-T(-_>WuMB8mL$zkK08Z7?+tgNlwVa2+2_wb= zIOV2y$_>EJsilcjhw2<@3cE>@swH2lmN3eyucWZE)dD!+_TB-vhf{72VsM_KH{q0< zaLP?MEJkTQAA&%X%@;gp-f18!ptxjk$-4=1mh24?}d*;&8` z4AgoU^(xPVIfynxmda6w@L2Kzt$!uA9!}L#PO6r}XKJh9gi~(9DL3JioBC63>t2{= z!YMall*OA(;Nxtn=F~jg>-Rj|j#4il-S z!D$R`Hm*Kks@9A`wS-YFfK6>lr1^1{h2HS!-C>O{{Gex4{ zcqO;#2!j(&xe24Jys0^zQy#W{hly0vVA8`T{wrS|-FYwN#EegyTv%42Uw6 zLpW7SI8|#s4uhM51#rqu@01&WKLX0}V}8~B6g@nga?>>6Hr9~a!$HnH9B_LW^(t$G zIV3hiO20jva??BICY*9JcPTgFl$&tMO*rLd_fl@_eV8@EDK}x1^!|hBLsJS}KM*59 z^@~=%Ao{eR_^#NsXQd(^b$SQ(=;uki6+SkivHFJN+7Jy9seC%c zg$rZxTPfbE#QKm|_nNJKEd5YP_YJx`^*beZwBISg7T+oPRZNl{{LO?;O_kEf_cZo) zfARAFDIX*`(S2`D@lX7*!Qjwm?m12x?zxYSJSzW=CesP6jZVNPrAxYh?lZxDc$rm^AO;P#aX7?@8*LU}I509ial>cwY|K)R2 zgS}gaSK+3g4~3)N>%q0Y*ZZP-1fm4TcvfkfA``Wp*o9SAeG$z z!B#dfeGiwP%gtR2%gr5N*#N#o*Po&5NVUp69xO&$$`0&CTJ8tda>HlVvSZd418cea zGi%u~>w~~rZUxO+cFg+ez*_DL&02QM`mMD7obM%W63rZYW-qslmvGD2cNq2@WY0Ma z>F}ibi(Ko+$@D{>`=CChRp zXA?ZwRPKNkXm4sYVDU$^1;!m2oIF{HtUw!CwM%3gqb$nU6p(rUjttw7Oo57w#zt0@ zd1JtM!l5xrDtp$av8C)T{gz+$h$oB6LqqjF*j0E}9t2crsm0#hjQaRWjM4SML~ zs!`QL#-%&TN-u$ckbXv3BwD~K6afn*cZQZGUoUrt^RO!H9u?&W;6XF~9a4+fS;8gWwi6OpAvD zFrI;1)1Ei00v*@_9H7&W`oc;=Ku{!3n3qEmG;XHFft8 zX>ghb!O05E)oh`MhlAiAPJ^3KrJR733*a<3Wd=ECSnEX?oNy4_!iH(_Fo>$)9!`T( zV4$4dX<9s-2B%RP++u{v30v^;aH#?HH=xp_-Pfk_pav^>%E5a*zRoJf8ZtKqjHf!6 zn!d3D)$^iDRHuwh0h#yj$gph(xlg7*MMh(Ush+XDG2j~yr{(ddoes!~KIqESK(;tF z=T|0Lf%OzUX52Z{2~;Q`b`02~C067>rn#9(TVgciMuOCTU5V1(7vr5-ifsZa_0 zx}3n&lqjM{L#2d3kC08wr4g1#Hv*=;C{e#4CosM7aN5mIOK5md%@@7G`N|D+=SEMgwx>cUT?M-UwB_V4+p`$Q4rk2 zs8=2Ha2ni)8I5p zgIkO+IN@G!3)o$1dco)6URvhiGtXu%6!6`EsP=>W$guw|1!7Xf<77v4{3hv=FI0XjE>7Ayps?9C^*p;OpL+0Y+lp~8i;3OOP$ObO&;fiKi0@7N^+7DRC z23E3x)g=b{T?QIy?_}`@3`bhZ4$Mbd$_|`HTJ8s~vJ%8xWyf4)$6PN4uCgq|TxG{x zWyf3(0#{ihVy?1duCimUPY15DV8mQy$6RH{T;EF9KlEwB;t})3o;hRByk%j@M-IuV zhrau;=PrBBUG|*&knUI)Aq#Ewzn%~-FmYPg;N$7)4j&S$J5&_cTZD1B{KUm&gNw@s z7Z(jtXJtu>iz9n3i0rutMV77u33YMtijSw=^C9d0!iBni->2?1dtMjTwpb9XZSlH# zYg_KpwJoRHwJq3UZOb=fS?vxM*6L|SyUdUb)yOjPJvq-z29I(W2bbR<>sxLq>s#Pv z>s!!6Ti*g@?cPa-^0HNdR)+RmBGVXU=?0qu zW!}G2M&m%4H|~_dDO9FFrHsZ1Q}x*!1IGJP8lx=TU{j#X`*+H)4V5WSDWh?q%o}&g zXl%;LXn145=mw2ZvK#VF&gNOGuY2X`0o4)#${PX7(FYJDUG6YP?Qo?>tu(wpN8iji49_S@t zdP7-0E=(;CbVpgb%M1i_&I6{T5Gou@EtrDPyQ3Zwnv$h)VQO)~66V*-Qll!rltp)x zl`H~*UII(>s!>bW*GpPtxgs<@ArL5`hbbw!M$^)qs8N^`x?4`d5%KOYwGp9vpqBv3 z(nB@{bIt>S5``DcIqz;tN=Gtxn4%-z9ra4Dj;>w<350RgsLC(n(%pJh2nj6F>*MzI z(ml`%vv5ABo;HnY6zZkB=?%N<-C=5Jq&vzgjRXR{OjGX|VG~PJQZh#%;4nXumxn)_ zb8Tk2AMI*@7dO6P2)&gx_lAPdo5Q8OjJyeJUvoI+rgzHC)Ti8pQ*Od3H{q0d?x8zWNOj16u!zT6R30 zQyvb~dKmR;*gc$bGa|jl-rDo zG>!)Nn4>CF^Kg%Q9`13^!%z@%o9O~L<)(MaO*rL---3Pe|N+aFjTRy%{nOr)ueKYM07Uhw9ol&u9IU8A;e11!_HvdNu4GHnm~!l-pv2 z!3n3_gi~(9DYuRyp8&g?bK~n?d8FL*PPqxE+yKJ)Y?}R*tV$}qQ*OcmH$`!d2IKAF zpx+)2xIJv#;pA1*_E=Ii;jzSkOh~GhaH^JYs+O>+EqNw4%aq#$R+HCzqrL!6xdHeY zvNVzE&?XX+aSgWacke^eOpz~DOBiL<83H#GlldZ?CZ z3C5M)gj2PIO>N1u_fEOtw{%=JQgE^9k1P2YFjY%9RZBQkONum8gi~%47!Ctrlm(_7 zn@v7blIMB2$2||DWkK+in`7MTw+~J@m)J?tytR4rkY z3%pHjH4Fe2j1pnvUfNf6X!{B`9NN~u?lhLFB_GPFGn&KpH_u>PJ#1Naz%Xt`_Fk&o#Q*L^v+yMNXTAE08sIGmcu$wfg zTJoi838Sp~N(wt$Er0`V?;UV^IOXOb2J@NTgi~(9DL3Ji+XROFHU$gdl$+iuHvm6F z%H%b_>i(I4hfxouF?hgjtRc6DE$89nRny=s05>}e_<(_052IdnhA;=wX2?=G>JT1F zKA`olq~61+TFOb)a(ql}6`XL&O*rKyoN`lt%5B{X^GrD9CXBLpvk82hP1T&5hkN~= zhkM-fFj_)x4oR=y-kWgBP1v~0{L&apZf4+y=|0bws)fsfOc6G zd4yB77Af>5Y-+1i5Kg%XqpX5+Ol)Q?fCFytZQNlZ)igMb!Oh0i2TawPQK*(M$_22g zEr~Qg&a%)Oetu3Z?W;Oe=SYqQ`4})&OBm$>IccUy6dbSQHXUJb!YMall$AF%r*q1~ z*6%QpY8p%$xY?xf0a0HdCsoUYSf-ZBQHO9`DTe`3hH?m}Y6+)mt;b<-Q?LL|x#^v9 z1Mo*cIeyHqx}Tzlhf{8v2HeIPa(g()xrYO852IdXjWCDAW=QF`hf{8Pr`&{7ZssoK zCY*8;PPqxE-0WV;ZM_e(MmXgrjFR4E>AmuG|7Hkw+Bwz#XFwFVvCiMU^;ocUAIY+% z`#M|t+0=-^gVfO^WQ)~xFB zxG=T2oC)A4D-KCWV2NHeYV*+7OLx;7cGtV>Y%ATRJIW#p@w;sF^m@SbN+=HmN)!$j z#&$})yD2Fh$=qRz{L*m+paH0;V^V<>SKChKlYeOLw&o{%v-5?0LYH6heiAsRdIIdUw=A zLQ}FdE=(;hSi<~zS!z_}m$K-NvXVt0&`V&6UNve7`+7;+JEqxWdMYIbhkz+5xkl5{ zoTyQl6S`YY!V&TAFtrh(J5*PC37{+hn|KoL<-`Ml5``DcIqz;tN=Gtxn4%-z9ra4D zjxK+h?w^BvTs5ll%eZv6UKK(DOZ57btg?j03dc!n&cbHll>5j5W zBY{9K(-h1H0;Z&7jzGXc042%gVa#Tmua}3Cd06?ntV}nIe!u6@CD=Q;gp-+ zDK}x`4#!oA+BdK2(7x(~8{WKH|NNa`HN~mcG%tWtwe&W%6*qwJtYdzDl$Q2Y<5j=$ z_`Z@aRZAFU)mH+H6pX8fgK_n6!0lnwLv9Xx%1t=sCT!ecB2`B)Y2ap)hH4owRSTDJ zTelF0r(lRG?D7io&_MeP9y`SnIa6$p<2QyE0{n!r#u{R zd)TGR4pmeOc74GO<>4P7-fMe$7Ykyl;n9H?s3nnao`t5@gPPqxE+=Pw0 zOn0RS?l;t-y5I0}tRD82aH^It$_3u0wi*Ti3r2~saWCzwI<$R-8xC#jU-w!!RZBjU zRcADZ?!Dmx*whwo?;UV^*to;Ks%dZc(Vz7oK4l7numM+o`-wf^DtUMZVpMW-`<;W%1zj~%ly(9OKxW1hUq@fm#T%! zf=m%MwM*rwLv_F5v)dx(~no+2hFvNE95e zbza(nn@!;@lgt98&i$7GHrf#YcO5em%Zz+J*3WWq>A8=cTIFh zOhnlrGQGHR@o-z+U)evh+FUp~n4W(5<(EJFw$ozy;n$8<_a^Xpd2JqABlO`b-#3xY z>$f-XEu4E)3y4Qb4;fH4(h}4VH=@MD)0Ne>hpK#kwd-it#We24Ufhd+w7BnPujP|A zkR}^wA|=M{;eJ03I;w+?Ma3TE>XEZt#{CiR`Pd_g4@2&C#5@rpO4mOxQ z3OqQxvbukSxKcBH2effxejwl5siy%G@7(qe_P58=wb{6zd9~P|jk^)>eGt9K%dg7z z70}&$OeV_uR6oHn)h}>N^#dH5!cj95biBWD=H6d>>B7pv;d@5Sy0jc&`@xw{eg7BV zxN@)p86bHG#UGbl*gxoc>G~r5!Vpp83VJYE(;d;f`qKEG`&J}~d@AM8+V=E5nYF{& zcsAMJUYY6#L1xogTcZy#zV8tLO?=hV^We~}?Oilh4a`E@Nfj=95L zDH|O7J4f~&Tpg}7cS?Q4&3Hw*4w_F&jA`@nZU!7)*_6?pZcYx?*N*QWS(}{u{OYc7 z;#j^@wkL&qJi3V3=P@U6=9so;jp@PE9T#Ig_FpOXBONr@7f+d~^ppb}C!Fbl%pbI< zDz<4=hx`2g%E9({JUQ4LOJ--2RfU-0{VrSMeMu>@AZL*6$?Dp4HsKee&W;8ZC9Hq5BXPTMyy94Y5O1WDM2@H%a30ACzp4Hz%9Z&6Um7 zE8kFrJc`c7j#y1gh+W@P9|kFT*o#eop!Emt9)rl$6fk`$jE@ z50$i4e3ycDI;Dgk_aH5Zd!4q5dyBS;`v|(|)H~pL>0njIRP8!B_!d$_P}9Mxrh`>Yhq|bo*9~_bSRL*-8Jd;PTUJmlGBhg$H7l%Y zR@APOp;;lQSz%SP!m4IPT~vmQ%6Ix%@r#xQRQt1npr(OUO@rF?rZz!M1FM<_Ry7Uk zqGuN2|1Qt}kBlGHK0iU_XI1&tuE$SM`B_zdR+V2}+B4g*ftrhQ^Ehn&fz+BG#?gYk ze~7)?abCjS7qItTJP8e(?-tP)ci%J|om-a=SjoW ztc~Cme8V(w| z;b4Os4)xUGsQQHSK|3CF!gOrMgAHyx*x<%P!v^E=oAr30AsP=Fy76Fx8xQqdIv#X$ zdGj2X0W%KP)u<>8Wb$;CZwv^3p;gYZJCneChxdky#Am*9z@S<=&ZI#EzWNrmC z|DjS#BbcQTo8~U1mPRm3BR0)7rIto8OCvVT?|1|X>LW#H;snp^vU-8uBN3r z`yG-;kKWl24bkC7LwC5b!5wbu$-^x_-st6yH#WHAjScR2)3|!P;oLMHoRX=d&jHM7 z^S{anuTQ6#SQjp!j~Au~7Y}g1Iv#wb?8+BmXObCQIwUjLXttX#(@%%q`8Gcai~j%x zCWXbhopI^`YW}x24rYswa|T0r3I!00bDDALF>U@;8wV52$N3;e{ZFF+(%lsC>eW8_ z@X94q`?LOvC7_KL9)4}G#PqlPbxBNZOkipQf;}4zam4lEDakAT>lhGOVvqo&2l9bh z?%~=g5H{_Dxj@`g!!Cr{UF0pSJO(vuG7;ra9qQAuRyNRbZrB`=ZF4^^9kR3Ocect7 z-Ye)(AB7Y4^oYW6X|vX2>! zD>A2`v|{eb29s`IhIn*oe4r0p2e_x}b~r@7K}W_n=zu&cdj7I>d#n!4PeYm>*8omR zr^otUqWKvBdY}ThB4asj5TN-l0qBwUEknNS7+;!D(8cCo3n=>M+HoCg{*MI2m8m#eHdQp~_QELYmx5qPm7dv?Hn}qczMTor3xWsoG12K7edx~9s z1*5wwGs<~zXU5k|4~%BhUA$D{mA!2xe;d2&!77zXRHqT|dKyu@C;guAO1Qo$nlVE+ z#?zJNtjw&#lhqYYT)jTOFp)PB&8r3Yh+GLYnZpX>ehP!TV8Z#E@LSAkCADQ#qj^ zwHKrrRu`oC;zV9!?dx@MGWhG_iT?!ag6bKfy-senM+^D|lM6#dr7A>rDv^C5C{9J!&u@;Ez2(0k{8M=s z3klji-I09}({*gp>U#Tj@tMxvSEtnvdx_CQWXlC~i( z$G>m^hwA&^coCb^ZB-kW z`Je8~Xr8+5oww|tI`y`9PM zIQ_PN{le?O#`DUvasRP zd#96h=cXYEAOr%zHVFa3j+g}8#SI-X32vx}h#E}XMg-%AI^%*HDk>s6F1YWzD54-J zDxxSLGH&BOZsX|a#P4}do$ho1=bi8U`%aX5>!~_*&Z)X}YdLl5_9inwbEDDChOBU$ z5m}J4B3Di><5Rn_oOs-`__)(H@I5p?=V>Vh-NYdTXMaggLrShB`;bRe{l| z5-wJiP}XTX2`mTu|%G1+~sJi`H5C zS#geSavAI_*Cpz+azT}=be*1+OH{cosB&FUk?J23#weD>m=7D zs$3UTxh|-3HA^SC_jJf*u(Mp3sB&FUW$wb^@>vFbj~nXqT$-F7By zM9_%Z5wV+pW)wLGMEo;zaL1m>G5(o(j7j)s=37eKrOffq^cYj@pIK-WNu%iED2!WG znp;ttTa|HRigGKeFm6@GjTGcY3guRW(Ms2T80$oaU=(40q@dkb)b4|-kGdLn1ijq* zm7!)$ccT8>z4MNs(shrStgO#WR@P;wtl64WZ>>pDCrzreH6isDniN%JYf_!93A1K2 zVb(1)DS~xSE%*a4^3ZT!)E;o#T(s5?D0IUr6FHW;#4Frx+JNqEtRfKyC=zj0azw_q zDvNCqM+360LJp4BOiZ&a!t_Kp>8gw^iO7~jX|_ceR|fcl6@iMC*2isWO%iim|-AaiP#3FdJgM$xBEofO&`+ z-he`iSmve%z(}XWoUBAwCDc95%e@*2pK>&t3J_V)7Q_(=kODdq$uVtZNe&9jIi2-N zGc(09+)V8>yN+bX&lJzf&G_uxR4OaCC7U)rBvv?^*PlA{*$*oX_lTRoe}W=DTCJx8 z74n%XJg5`x!JTOvJJU|;M0;pw+QYMGU2EyAm+Ng%5^9HPr9wcI&pM|mYa9b&#ui9pp~G4sxd;Fe@ZW8M4h*hWWCUG4ixBMxIf|$TP|qd0H7G&nRQ$8D;o5 zvXmj4Y-LE7t&EYUl`-;+GDemsk7b*RqY z(4@2ZDKI69EUX`T1n<4;8)Ib`PWiSbw`})s@ zzC&Xh)&EV?qPi87Ul`2MtU1anNBJsI6xLSmW5U{s5Vtjx;RtLnG;OJBDe9e!z}%XS zTU_hnqH3nBOZ6f_PB?WA6omUBJ2Z$!ZG<%JWun^Zbc{=#ink|qKB&V^k5hotIoLZQ z2XCss%R%dt<U2Z) z-%m_>31*U%?h^#@?9}X+?BW$TJqnzx0;gO-$Bf1+bOeQtpwJN%W)XC81eu!_uZz;T zr=QvSPImP?N9;Lnp5x|aaZ5Uaq$5Z=f@Bs!7nhQ`X;pRUAn4d>PZkvxdtDvBu8vVc zkv5>h5~OJbch$^ZLP=a&H?PR$D@x}hrAzCMG|g9(&PNqAt!(qmkL$_~xwNUyEB1=D z-|$Mj5*O&^b#sC4UUv<++R+yJM>dik$kf9rNTYDF7lTv)Tk59+KO6_)jY^xgFBhjLTrJ*tnIX*>+2_ z?cRwkcQ&nG4a;qQks9UDuoM(?d$_yE(^4{u}6)ZKLp!n~+XextgS802ZWa6ykX7db20_;rjG(FpXXE`wv% zkfXU9UG_{Ir|5{|tlD{EH>3&p3>sAZNa}19I5*O-=ICxr)>p#aZ0@L|hzd>VHliCS zu-oP~FOvvlf+VZ=Le9(EZEmb(r^XEoRaQrg^3>CaR5jfQ5-E2eGfpPi@Hh@7bFkOf z@n*0Ya>vwT3uQp}L!4DS44T70Uaf7PWjV5y-v$|ns zgRC3ul(W8}uYYG0Jf?3&6KoUyvfGy(xue@gV$&;=wz)eysC(cU3chbT8E_N|S=0_5 z96PK(CJ%)i3P1t5(Q#U_2=yMT4Z`ngf@5%of_WST2Oq!d*v=&d=PWyiVITS5oaKLW zmRHeG|2JpBM24@nj;a5fv#{$+xTykry2Ae_<}Bsuxl7M-H;3s}So&YgTXY;pKa1H; zFRwJE6PRRgM9A}APn~i!IiC@)v}1DS_0ljVdD;8?nuCKg1{_U57Qzk}0p!!cyJYKp zoK!Sn$RtDL%gpn1ctk!%U<$dFJRQ+6rdmG2PVyLm8E1CBHE#Ivdbcj%kfgSWc_{Eu zj3wLMMWvNSD$YE9M;bCC-vi;xILvva%PxJBEmz$t3MJGKSK2RLc+v?J> zPa?KaYiJ6pDExn0OBbhWiFMQl{b~2na_Pm{r`@(fV^eTagSr^RvZKT5l7<6qsvdM| z#92u`FlPsMc5i)L*-m$E+d`wmZeB_Ew1|6R)TPufG6K4|gBP)~mEY}oQm$TqrzI6S z(*xC}TLc!Y+I{JC*hO^2mbNEzMwY_LrBPnabTy{|wAD4L+gLINlH8GBr+Im~Ta)C0 zB!A>H8It_1NXnZqxaMI-8S`S8IC3jCItD(IcuDi8gCzvJcFfs(GLB_`0X!+ylR%gK z33r;}5;O;N$=p&&S(w`@8rDL3ajfo@hv&yqo<3&DLa#6~)QjZh;B6?(g?+$_CqtEa zUSV`-#4F76JjCbYUCH%<9j+zE17P_eQu(WdlCjZtDFwJ+Sr)Z>6TZR|a zy&^4n>$~(oukrUf`qb%sazmOhw++etsNRvr@X;QwfQP^N?W!RDAj?L0jZtEnR%e1u{9XELjHVe9?pL6Nty!>ml$Wa;HV@`QJ zdET~!+?TkeD;?idC-w-=2WVR<&N95M6sg}9H&bi~>Os32inK*x0$l-s?$6a zxmBJlk$D-JTUBC4<~CDYG2Lo?3q7@%Tc%{m%+JV_`roVCZKk+#ZZnUTbIX)0naN-j zN4@RIv#u-las%TKS4{YQPI9*8pv*>%u|IYM1EYZykE&tUP!FlaoU78%SRh<&d9ygR z)v1Xd3%xX7eE>su@J|V-GfJ)!tGac8Cgs@VO!n3ONJV4{9Z>1#0%eke{2braPtFM) z9Cf0Gr3I^vUrqd35;ZbBDgA8*cld_AAbrl*9CS+o{>A`wV`CBvPkA_cE2H&Y;7A7- z1m1Fs@+t>K@;PaU=a55vQ+|5H?_GtrXJE{t!-L-s=QPHm#SM8;mjbV^5zcNcgPzW+ zD2R8;)cC^{;VBg-ZjpxPp_K!7*YtDg3CZ*s4A4tZ3o-<|*8Z?R*cf)ipshvy&BgPz z%r0+$HZ50=fkyWLnkL0MOGIb25@l@+a70B31C3gCt(So8Wch=Bi{~>lXY1gkm>UF# zL?N7q1^EzphbLti-im70hi)}!1XF^k`A~04#XNtYx<{Iu<{zq0YOp<9@JC}Bu9Z~R zpt@Dq@!5e9zc$GVcWjd9!JY0Ee{ywNt@}f;AJQzQst$C1!|2Qmf zx_RkBshyFKcwx2Pjm zI%*+9qORbTC22Pe{E#bm%9vAbF@X}Rz981SBbsC` z5VN&OX*;9&c^gH*a}LR#)E0l421p_1lW*rUw^akGO2-?^LY5l*3GLK1@+25EhCU-(&9aW$q z&9$vKSDWrMtM)9p*>zFub_29vnp-xZ)o4Z=(fY=f|)bRf)ttq3z^3&?jU8^&+A z>G5$ne<1UQ=9r-a2i)PdO5_Nxk1IEfFGT6vDMM_q3Pi(`-~?{HS@zNj0>~*`J;F%} zR~J*cdTea4Sls+!dShjOL}S~=K}NGMjg<)t;YRvKS|r>ALPN+SHCWG7MiI>h#cu$S zQ${tv7s| z#~6plM02v?088{Nxu>N@9d+E%f`{T{$SDhnsG$;M7z0dNat1mi8q^@<7MtFWXbwXZ#A+UR{njVXMLvpX*Jz@ z>|W9>waCkQcd2~9j|G$V_Z>Ch#wl1&vilfpF|%28Ty%;M#&JyMPWR23`9Q8apyjs& zHz)*jVuNo@DH$g&9W#oGmjXIzt#@5E<+$a%hFmpnk;9HolodSNSZfY`We0X0QI~e_ zD_i;Ql{;}dtK$UPNyc9biz@3G3MjO#u7B`V&&GwG&kR+7$n+cfV^@B7C7 zcX@+y1muoozna;raovQQT*32Dn}A+Yb75%2mI%h^%%}8dg=tE?39;dhGUY`={DdCP z4eK6Bux52s$C~pX%dL&|;Be;qdDcqZV{%tpC^;j!HP~?a{l~}hG9S{5G~u&&ICZJJ zJiqf@Jk}sTiieRLCRg3pg+5`q5&DeFyere3yUKEF5m6F02P6)SitoupYyPYabygL9*JI zV0>^-CpdF(M<*|>9#BkP@-(J9Mm+yN>n{`uIZy}9pYRrQzA<`6e z3h_%7bhu*OG^XWgKT(f5W|v1LGoz#F3{z242s=UgzWDKx8Elg21gZ|3KOwgbgnaen zF{v>aYS7Nkl`AIIL6M!IGv|!kjcba9QuR1&R8>HX-7|cClz2cv%Z#ARXc-Zb8Lej_ z;tYGemH1G5!4>T9QbLo`dpI(F=om-PDK2Fk*n-0iPOA0dn$zMS87{puS{}(r4x}}D zg7F__;&EcwRQ-wxIKOSG;?n_5>7;VRW+z#33(e$IljBTH^}`EVrjqoT4lb@Hs&eAm z+GJ2c%gdxFk7VSaJ7V1x#$7ixJVygS&tdV;K_aVuKZX_}H@?Y-<91jDzB zPwC7$;ks9C6&-J%JD3|RYWWz{slS0!?~m|hn9c2p-amK6_Cgy9ndc8F`}`dB0W8;+ zKP~Bc`DyTv<5pra>~4{wWV9%m@Djy?%P6Z3AMnN1DRQ!(sLJ3LB<-A+Mley1svb zI6m>IU$l(Ib5a{om}Iih?-i7mH7a1Bv z7@K*X*TC&QFERLfuK?S2szmK{?&2k|nU+9QVq|whd^;1ndamSdOEsBWl*}u_E?{&@ zvPtK^7kT)6sh8hipLEt^#n=LcPc~S@v$py4T%zlv zuvFkaKJ4zH)H?8YhI7{=$3yCDr|m^MQoNpDfJXPwbNz*q8qdkRE;l0lh-m5^q@qFQ zq~1lKq8jsnP;qJ#!(6ML7Z84TIKBUDj{CVm?eX>W1dO&SO}GD`V5rc&s2Zskw*&27 z&MjlzF6kDrUD23)e%qEdzr%v*&i+r*^zJljzHsD9tfn=;TT*$2+aOGKO?DZ1Wd|Ww zU|yGP5O$3Rt9sx$b!|e+N@B|ze>x@?)Q{N*`3sAX4I;dP>27u86*=F_jveb_xJ4It zjGj(k#ANoZGclRnYbz61+YvD2atkicyd}Fa?K!wDJI`l#NF}L$7vgmK{`23z{f2^JJ75xCq@b76gjX)aTP~D9U*J}lT4^~d4zFVr@6R?8 z^3??aX}RdmIDt>PQwO0&^nGzJF?s!b3s#`1`=Gq15Y> zrGQKDO5HYnzSnzOsm^Dpbv-xk!6Bi{ASy8_oz0?h(@~C=YsaB@!nB3@aro*Lcphmh zal3EzT6j!I>9g)!Q{1)5c@5}XX+Pv=-?2r0{RXy;RKL@J;^By~Q+*9Mm7U+uI5t20 zX*0~aaG=rIXI7ljWhbP!_BXIr*w6WOow6Hs%{ba%N5eT~4;@g5Whvn-tonlluqXy+ zVJZj^n8lPr2~MBI*vfusM;+2?PdF3~^#RKYDrR%veZq$^wSE$ECW*C<5hlOv`gZl@ zpUI3`sv6r<9p+xmtdeF5S%C!|n~i^FMh#a4)u=I&u1cMUUIIM^pzH}a{@^ipe%jej zYnVS&VX5wOJns2Zfx1p@!419WzrD{~SyYdV|b2IIrb$q=wvVxhPgSCE*LF&PB3&e99rO z<-HD?bhqTI^TFkZumYIhP|YJ8Rx36azXOpX*cZOZO@$%acbyGGA%b!G!UEoa3ZLPMyks?>yYq8I}{41KB6w_ zK9JVs6~3N$Ebrksb3%B|A3rBt-31EQ5z7zZg@%><&hvh@9DQH=ax@>t9 zmsWIsu`_~54R)j^b33I5f

g`Y?_Ot`*o~BkI;xSU^l-`V*OSJc(37+;hlkB?a0_ zv}BHtdnBgUYP5;Avtp>kx@e$!L^N1EB%F{yM^j6ng=gAbLR_sn_8+4rbTw!-Yb+lN zO$mkFM~qM(IWj*Y{Fd?m&Q$*IOeH3S|2tFpzw-(I&!4I6>Gez>QTYFNG@*VRRx3Ox zit`CPP3w*)jKu zy~}B7opbJx&T0CjWqReFN$PLK$W}B-n{0Lw(*lvr#B9H~8mfQ7f2sz4IfLUSf>HN3wZK zZ%L}(z!H4j(B;QBR}td=RxZC@J}JaYUU^iRV|qv~*k&Ptp=8kQ@(l`vDt*CZD2;-Z zzECop6Hag3;6zCdjMEV>n2v#c4+y>i5vOz=R@eAY#Wetbkyna_jh$44ZU`ToZwN6(wm>-Rw< zmHB*#ejh|q?)xCRkgphCM9USdUH?E!uNYpOji5878Dn*{) z%xn0X(kV)D16;DM?Hm)zN-bOW(0lkkW)prf3EL2llIKX>y@Kl>!6^Km)M4Wcj4m7Z zeP|Z|=iQ+Cgk>pgYw&OI0y8g#94vCLz%4iH7oLJMail2duDYrRV{8r-24~_?aNXHf zr=Rqy^cCer2WmMiKL)=^vyj)Xa{L^FyMq<;<(VR_nzPV!ND7%_qR9wc$14 zeA0lp%i!!Rm3<7M>9{3mlRHr5>|^vjz?p~vus>5sVCH2M#n{Tq>?q{6H2p^QRxI2u ze$rXRVZ&vG=m#zsP^1?%&bdVTmx2b~((#d_{HX5u^x(|v($Zn~!P#ni_8IR3_vB%j z>9AJ8n(Pi{d4;evyQ5f{k1rfXb~N8b2P9l+yh2yFzG;YpA#U4+O8TpI0nfw9#T|3% z0dtTmV>+Ju33w%8`^trN8s;yr2W*rbHmPK8Wj|&z`ZOg8J5k|y99h8vZNF3&YNBO2 zFmo=XH^H{e>hKa?Q6%*}noQ~kT%5m6vbbEwprjjhV47qLN40!Sx^Hmtx;1D+_lo#4 zYT92VyJO`zPI9dt%WKVs4P)Yn&yjRlT^)wc}VAS7E zt4>wNHyyT98rUwt6PeQ(rw_Zrh4gZk#BH_m_uO2t-YbUmQcsQ5^8Kmi0X>ljt~W6P_l0)9nfK9-JNWC40+Khx9N9*@zsEb(9hPf>;S7LsgxyEl zeH8DiVIxj0xlj{B%snX+bx1nu&~$)@oXGHSyB}fq$#(CA=aB za*Ewgwfkwjt4f#o(xJHwG56(}s4LP@*c5PSS1|yQSKED_-LK*O41Zu2*78Ara27W6 zgZ|L0^LdL0XU)Og*+W7C7WA2cor;}p0gM59_hDZ;kFN+3KYc_{fE{*D1nmbEVfZ4- zcfMN~&)u75&T>iHut~z`$_Zc6T<(%NI5&pxIIvfs;{wKpS`EMpQAb6_)zeUpvt*54 z?j*c)yulfdH>9@J!W6DQT#)Jptt!B8_#P22XZX%ctczfCs?p17%x0$k`n5REhtDN7 zczNW>n-h^YDT^7c^u`EkLULzD`N|;Q)X2x_O)uKu zYus4H@dXbQFrLi<8|R0h=G@mS-Kk4=oYVbk1CsUm+7CtO)777KaBoR}T6Svqrq`rH z{E{YWmv-umpVXP;5l~L4BVenl8tFfi?IoaWT~xXMOmYwCk4LzuLjRd;MgbM5Q608V z_&x}H0KLE|s04piBJda9*@{%1BL2klwlz-`;7k6xhesbm{_K}S#&NR6SeeS?8P>I|704G1@y!y0fNOF`Dj$^QP-PDsWFD2L;vWe}9` zFU04<3Eb6rAT^<5PSr8e?k@TmzFXypV|c?oUdgCB@(7jkMzc{q4O3TA;Q=}=Os*w^`6=TbnfgAo}>QZ zkN`}KnELUWLL;?HM$)DOY z9c-*fCs3j#FxbdyXAT#q^@(q*YEGA+=N#D#>vYo_V_Ryx((jFHQ^lzcYCElt`%{cn zpdZ0??=E^>{d3ijrPhd@(oq?uhgG<`$Ly95s+0u!-l7GY*8yCdZu&yS8}strSt6tC5P>oB`Yn72_w+BR8ewI=U{kPPs?Em z8KU-3M|neniM9S=-{U}3_kgXo!k1f8l>2aIxCTpWoelTODjmtwXJs6 zG4o7ZJ65R>&%FlB*!3?<3HQ8>O-7`9zS+zv%xIme88Jqy?|}(p8>ok*E_ml#u>g(5 z!Y$qMWJWW_@mT+G%MxEYj4u6PR7+J(xU_fkL7G}R%L2_4fzg-yWi;j(`*S>K>6_t# z4uI;$3Z=aWeT!eIoq#~g;A}E#x@|Ii)82FfH@-x7P#`kI4WG7{l z?en`r&(9{?_jk!|%qH9KcZEKXO}0PC@(TU!MZ-E5p_m>nsYax-)#|u@2tdW66ox!_ z=Ca3X#&{R&y~9BV{iVrb|CEkxs5mTeNkhxLa{t6)bqk9r3K+^QW?DRWKyfR^*jSz>#zU5x zkJRl7A^qLG*nnK0I;BL^B_2aZ+NCNQ8<7mTc_D%|(wuC4}XV@u$bgBc>Wdm>=5siTqi zY%1!_(I#} zWH=E{T|tB|No-C=!b^gO!FamYn_w(>Hb+V$f~TZyYlU*eIt!1?*$XzULEyP@ z7C2%o1HFHP3au;X5fTq}N6wjDs*y9c?iS{x+4Pj1y^!WSJsmhx1D$dfSL4UcOR94# z3(Izjt*eFKU;%!_WlkLek+RE{o`M)JoEHyf(TN-}R_UM<18R1vCtZF{<`CUkJL5pH z+8OwAp0#so7d+23NY_eW%97xvAAPEz?^Z^+xv}e3Y6`A+709A z{H-7SsB0)IJ^#9~O{uAP8Vz*a)(|v;pB&5=;j%9Gb{W^uInue3I*;v!fcp$X11!|X zfCt{$h;;FbV9@bGl=02DNnBn&mpRp&LZ=hsYU?>T>*I!lzAj3qf939@llsjOwZ4tb zpN%mqm<-}mF`wX)@Sx!8R7cD^GM87;g?&E?$o^*4Hy0yhQMX zxm2puqm6AH@o>S``2OUBuW*x(%i!YUkf+$?htXEQE$RGhIA=tcjWIdv=b$%=$2iU#Jj49k}pU0dfN<_sJq0R@zZEsQop zhuDCZHpQY<;b`-X02}@IWqXkE`Qv-t7=Lz>+L;mq6s+B>M4)7e7Vs9D1$(d-h_Ngf zqj}eON63WI+Kt;n5k$Mk;)128|FL%;sr0AcF72Vqx`W;Fi-N~1QD||--?L*$~d0eUl2+LvFBHO0}yz} z27I;&3GGEYfT2A?6|Y>1aoo9bZ&(E{uc~*xU4 zif;hYcJbM!JTF{@4&Jdvm!pTLiw53jC3K3inrE$tnyrh$177&i${Fw;zP#$_@JvQ~ z(WnwGfFX$W;zd=1PC(TO&)k6I@&P6N!{PSuBT%sQE^sVQ=FDOOHaxn(B$n!mOY`ki z|6ZheaBkyJlT~#!XTu)GHsqg)io+F^hASV0J%aY|qiODrXAyWtQYwD|!9@&nE1A{r0pL7-GI7!Sy6 z`D0cqLDIuaVq58Npt%*GxH_-p>JCDCJMYMTJHvtI3qjfZ2pC)fRsFLzqEz^h(8!T! zNG{O}Hb07Fjtg*26yM)Pl_zK7 zEfCU1QKbFe{50+9O1npf7V+033O6?B@XYt1c)A5Wj+oXbAe=3T31~qjY(ZFLSEf!Q zr}S5s9KTDa)jfgHwDn2k!GV%k<$y?_c@Ejfv}PPtp!ot9#d6JWehODFhDM01qt+h0 zW?6*qxa&jn<&bHv4`p9Cg_Wm$A)9AMUs#pyU>Ke53mwTq2yyK=sNF|p4~N3dPa`FW zhVw*6!$GIH2pYP6jlJY888ko8d=h}ax~SzXWXO7H+~5#sZb32>r;P>6A=4A$gZd5! zT#l^n+P!0{ZR!fLo{8=ag}MXbLTP>m>J1o|CCB=>fWEx`ndn7>ID+1M6UbX<;L`l8 z7PW&(xOs`jkTTr-9Kx(u*5l#0^Utpx&#Z-H)%rXLW+~U!r3{jhzx4$MwM%e8)~dgC z8Dq6`aIxA$xL9_(Zn)#zAuRv!i5!Aw7HB?!g+zt_Bdfx-k?3QOg$MX5uz2hN*5N}j zJ`~3@BX2r-QZCk--9dn%oiCqeYy!(HT7UCJNXhdz{|UEzfAgPlO9uVT7vrvzgJh_* z0Bis5t!-eG3>PIMBlm`m=zT}#;*NKIK9{Sm2f%>|?E(C#t-knIi+=~>U*)OT<3Wdf zRl*Na_5j$CjWoC=_QH{9TUbFTL7@3Wq#Dt+3rC`c(@7TTrY0j{hR7r^Yfkfx$XImM zRYulu?ZV2b2roozGKN!ImAwn$9O({-b!71JuDa^liC!MwD|~wr@88{^uX)Rh6^3zm zOHryfO0KpG(-HCafU5OH#5doIPG>~FE$Q{RhA|KUcM{kK+rL3j+d{(>^qX- znvEC*8@Tk3)ZLA2uDu1+4uU=ub77kXVa((-Xfbc0Ss1HyOYv^Khi2WQ_wX!?QtC~Q z>YjBU!}uZ;!x%P@jV+~e7Df){bqN?a04(Jp1^9+O$uMpp1v5XL+swi!5G>*>v70}| z1`-J`2;-Q?e0`hn1EO?6Cw0wce|Q!;d!3b01Aayy4 z6Vxkeg&^(*z1EkJnMEFel0|f&L+>#Uk&d1AL97H#(oV*zLC4IRmm!a{p1jJusLpx9 zI!&{&_RH+CHjwItLIw32(pd*oVH6%sSKi=fgj?%5(5}Mu^EXP#BOU$x(WnRQ=bKkT zVpZH<)VvCT)Ldl1oZ#S&@)pM`hx)@=sbz*r2i4#cY?@Fy3TGp-@~DN&udHicz!&pN z53la-FP&Ty@|T`5xWHdJwkF44+SKlM*8O5UoF3>icY{uc;n}pl0!?E6=2vmctt^W) zuSOs*s*`an1zrP!idmD}h?^6>y(t!{LharNf{oyr9^a~Wbmi{B^W5lv&^M#6> zk+YDzjyVV?RTzi$+W{lbL%<&hrLJTx`f--jg~HlOM*WD?mg%S{zb#%gn(Znp(jRt{ zgw$2w5j5bQKZljvNb%sV7-bPGs>ySKNZk$Od|C(Rfc8iz^CcZ|P6?NyT~24Yjura& zTe7pTb>&x9<+vGjkROUwy=+>h?`C?YXQj$NAy%%{cA0x?@hyi~`?QNlI z#gl#<;OG))`8UWhjJ*zBQ*Yo>=?kRR;Ewg~W9SjkEn?oN9R(pf48w`@jf6>Tp7V@$nJg?dIaT9E|fzyyfZKFJF&h(vpubD&bzoSrZ*fb{t>Q{rcGxoR$Zjpe5Lc|7F?Qq^1dPGN z?wEA3izxD^9sozjR_9b#qufWZZ&b0VH&J5)cJQk_2de!l$blh#mEge0%01vCseFS@ zP^@x1{3JWaj4IhOX8Uei#&k{Vhbc}*zgW0pwm-#J)t};&8v96mW$**`9VKw{!aY$2 z_G>*Z!90Sl2ucqxgqAu~$D;C*mYs2T6`$&jYFJd>&62u1rr5*Mma{1J7Al~knxlAS z8=nDx{u_0~W`rJ!tpYt1TLt^(iuyPm{^KGaVL!nM@A^o1cWzaNi0PV^zvv z_t|Kv*qot%LK6B@uLWO2&6Ii7mdW`ok0CkzC|=nzIo`4)EBC8gCMQ~!XC<%RGP$7TH6(Yl z`hIQ8WUpmIR_@ogOfGEsDl7LJTP7!4{)yzY=4-Z0?$UyhG$Z@XEt9*p!1-)OcT;Wc zmdQmeM{dPK-Cwtv!0+>JGhxl| zCGhn7Hgi1mdkOs6`TwjpzmvU9o_GFU0)3t)RxIACq?y`E_)6R})MF3ZWd8U6o(DHc z{&NxV7X7yh@xwn8E8hNn-P>`p*0fLfx>+$=G}rv!&*ioP_(iLU70>=YS%$Uya2~&& zH4*b^?zy~;vFAvPPg}nTTV%QznPO=X;hBT*I;ofB#Du?CUnPsfVl0TblI%Dv0vOjV zI}Vd}#&x$i41O7YJ+kAB-*(w?#;<2~obl^raW!OL(JRn=7hZmpb=-k_jq|x}YBjE& zI<$IF^^ieB29M(C8qyTXT9VkC6 z@C$;{clU$#+y(dGO@7&qC8+x~aRa zfA!DNq&(f1>dhy5M37f)z36hf6$b`P$e@c+|3qwW1yYY%oPaPg06l}}JwXDr#RIKX z6qJafM~XkJnt+t3v>~l^(5{t-0_DirKtJ?q@8wVq&_aKEuTxN)Ng>)HKoo;rndl-# z3FPvj@%10r-s^1XDo+H7E~_Ex1_bpacQ8>Y&_cBLS%vMr@EHjp%9~V0c`11^NS?c> zyee6t=z;!}H%Q)9o_lm9mm#uV)2`WpX`>7c9ze9CY|ykzluMm#RMbcH+*t}kl(0_I z_K>-XK2x-}_`*!vRVnwELPZl49VGiin0Ae#COKZwCe3$*ybxpB-kNr_l;#pGP&7?; z&SUusN?E>UIXA(yNoCaY9I5h%%KH#qAR`nlMGc-J7s>95{*BU}B7c@c6-B`16uDFm zQ*>UE=t>|;XjTdH<#5jjba&M^I~*$4%khf7=|*&;%uw{{_CyQiWX<>V4n%jz*_!r# zFNe-iB>leG;dyyX<^hTHF8pSPv*ii7PSZ|Gl5&ZmMW8%XR>@-J8K@?DP44sj$QMtE zoGokQA@5vhwik?%hvE$6LtX%gR$-!t6`i1Hqdcbh_U*-~mHb+>SgsiT6|M{GzCz6Q zMc-)rH=W|~rj2u{KWOR?+ek$pF&uoth2c-~CbkWX|Agz&gLpls=BG^jKjOb4=ITOT zODcJ-7{Tj@1AooXtm-HE6WfLr@w&MyuMfm|ojZuvy{mbh-<#Jvm2&X_N_?g;m_MQI zYrWovYab~pct5X?ypZ6vyIv!>hGlF`BtI+*lDz(e+Mv$wgTroxx!}2bZ(grU^7`iX zyhe7+1?BLrq#vnQ$DyW>F>8vzL6+k>T;}ylSGOq*!`D_ad~4--xGpaE6Rwx)^_PMx5PnI+Cuz8;U_N3F@<=~g zHJO#T72z8YPQTZSYHo+bk9+fagobY{Ww;436WZ2vxfR#j^}0f@3veBbgC^wp>}Xzh zOWcm@oyxgh2yF87L)4Mk} z99|9`t_<<&*YJKCzD%#<^?L1~<#-mi#oKTl?Ry2+=J;Et&li~|GlPBNF}bRrL-T{f;t4r<`!qT#UZ{yy;7(hr1-HuU`_NKxhXzM zx*Dq7i6~{LW~b#q;|#3@QGRj_vVF|?s4(Gi9|=(!x|3`6r%o8n{S5<@qqmIK{x=&J2D z#dne?4b83i5a>-qm-Qn0($Ky;5QPRhT}pcr^)hs{<{M$?D9yLOp)$=k&CvJTG2c0c z9;)~h=w?F?_9A-D&`CQGy>Dn-Poj217ihkoFg~!I&Cz^g4DG7*z)G z%Yk|sVt;n13^v66>`)nRi2d1Na+skb#}b`nXxiB2Kz}y$@Tg7k!)1Y?r$#LYdfd=b za5+L&8G5;PInc+3#*Nz)pDaHaVvjjlyx~qy_L!5U!qBVrL^~T||2bI>F~t6JvYcY* zp0Pxi8hT`GSz@w0Z0I-SJ5pXXlwU{miJ`7_Wr-stZ-kS6%D4~XM@hLM_Q^*{ts(Zw zN6Ep4euid8$qYk2$U92r8Df8ZwA^Qi{q@nZ!qDf?^Jw|2p~}%j|1vaWbXnqP=}m(f z-oL$|=M))b=w@g(MGi1@2Q-@^(+#bLW>e%mLvQa?mY5=o4Go99W8?)xr$Mu0WTT<8 zMwKOw5x?35=S1g0&#BVGP!Z%!m7#`?g`QJoA48`?v#D~7p;wjiEJGZRrpf|C9FL~T zGln+R6Kyoa(P^suVu+*DROvq2mHoA`M57E9j3#O@G_a27G(&U75zRBiQEjR`Vu+*K zR9S1Nhw}X15XZQwQn;g&$1!fI3^w$OQtoePAC=y0Xp&05)X;+}{T@S4sPtD1eWTL9 zFtoe!jE!;fu2bnd7`k1h?_p?rRA65DsLl>y@zZlx2l*sdBiXcT~^WhPtWr8x4(A=}#NFOr>uy#PNNq{L2u>_o>pY z)=BTDJVzShXg^gBHpJ0>s+?*ls64MWG*P8LZ0Kl}{-&X)l=3@6OI5m8=j8pY(gzvZ zU#0J7Xpu@k(a`-W{Srfil;_=s&Qa;B49!*PpBws{Qs&eh9Z|HE9{ucCw-Yxjxe-JrB5{ESDvRBYELFOUqhT7PL;`qI6IswCmZ7IaH?EtXkgx^__1=c zq2YPUfu1tNnd3BBZHP0+Y4WKd&K!@EUkvS(ON42PK8Ia$mjm%^8=uPe{Wry@%Z`Tr z)qgqAL53Ff*c3lrjx}^=kL5t;7@8H^6hA?(HpIE<339I?&P`8{<%T}YBid|;Gu0F1 zXG5H+o*;$0I$gfYC8{*^uiUc42{OUZ?c0ACKT(b|v@2$+C(5~o_Svp1aiZL5C<-pk zveXb~zs<725NE&5@`It7*@ljT zW;5g(LoM5vC1%JohPol&N%FR#jWMFX8yX4CPLk;EPI`Z6c9QflRNbpAagywA=nUxD zB1ahdI7ReFLtmxJ5-oC*q2-X*Dvukw5PG)C8-{A3XRCak&eww|u!oa=KQwEV?F@0| z-zqyA;>^EQnhb^WiT-Ga>ws3d))3bLt@4e|p;b;Yv|f46HN@3KtK4UZtBF=wZD^hH{L0V*m7ceklQ%}CR~q^)#(aAj znxxXF8ER7L7aF=zrQdF-n@WGl(C5nYV?%eT^x$|WZ?a17Yp6ge#~FH4rB61*HBhUx z8R8nKRcLo-x*qoFd@^K?U8Nwvx~ zhPaYyl}8P|p*-I*#C27x{9uUds#ZzvVc4E?BOzs%49D*awV z6IA-EhOScSUm7Y<>ACwld4E-&6^0h6^gRtVsPtnEMU?UaLn~GKZH8`E=`R{usM0?& z^n*$d?C0biuF}g44brlYHPl5Zk1+JON3p%<0sX@)LV>GKR#sPsn+eWhhzYiLiE{=K0+RC?hAC-2WHeXya3 zO5fkmJIb@!&~+;PQbYTx^m`1o_h1Wo#nAIA{R>5I9o{OD15_T?sY4U3l5$9GfTu~D zA+Ezum)8_=9X=*;hO9UAz~FI-nG!mXT;@gJ_x4PjDfNafE8jCQTX>3_X*U41$)kp@ z*>TUrIr5XCTY%1!eg8m8uEP&V%#n)~xpnvj@_-?(!!MNA4RIZQp_CuQd|YccBrcR4 zGw6uKpJhS@9h{{XSo&8shqWzRXA?tlzJf(+zR`e!ZM+i0k(o;6SD%MjQ7i)5}LuKO3sVnfdi-4wq?o-wp+=yITShG-MG zRX#UFo4~C&ykEE5eC4<%|gMEk^IxzG^p6N}|$L$psUmM0C-KCxIf8~P`>+$BF7`fX@g z;w~v|a=OrVakq>#MBByPGQkjS7kA5PhG@IEN9Gz@H;m{`L+=kOOWY%`89Hsmhw*#m zQ$y#EA@WUf@-7)umbh28H?$G*?vr{$A3@%Ia*UzIAs@!?m$MAfR&u{wYlyaz`{e;c zw3Xa1D-F?Ba=(0IXlIoD0m(VUNgt20KOh6s2xWgz_Ao>{%7Zf55bY=r${B`+qO=dn zm4;|lc~I^)M61e!vc?dtDi6sQhGqt>0O2s^)$5I@Up~1va_LEP-hRzA%^Z6 zQI>dE&M~wUHTZ~JZ>Sqe`-nVjs84-a;t^SI=zP@Kqw>8WT5%qgyu+MyT5%qgaznJ@ zJSyW2(Tek!9Brr#b@rIdGISy8>@m5;5G_29%hQHv;dxv>FhmQ_6Y`58T6mt2E{8km zN1z6ukO78{MSFQd4ooA|=aX{0AzFW)l=BVI`tzhLFf;)*_@q2$Xfx{UNqODSr>L_h zrFgR7Z%*_B@O)MV8Tx!=S>jolXs8f%wnUl@&A@Y5 zA{QDu9W8l@+-qnRp2~Bw+|W`yhv#Iop_OsL!P`*H9yBaH-sB=zctvrLxq}0F-a3Y%oNd+*0|MA=>1YO1Gn& zF0{!lm63*0Lx~PHL<`+gIn@v?bW7!GL$uH>m4^)#3?q6ojj(=SD&HBRy>6*^M>~16 z*DaMnhQ85!`x&}$1k+A5v|tR;C5HZ?T<$h>+z_U%GDNH2Qu*8vt$s@-XNr?YtKU*d z89GGEw|g3C`HnF}8{kqo-wIn&T}DEl(GK8;ZJW%8sUS|FFnyM}0iTqZvm`XkD|Op1?n($5(- zC$UWSG(_v^f9RMyb$peP&)O@cS`c?D&-Ow+Z52hRZ&58C! z*U&_>kYa=Fpa2GrnkdD_rt;Idpc82YYuPGY&_Pj?cU zP!lVp%FxrO&lNJ>&>^Ua6>^-RbWG1@T6DBCPTCYuaJKm zq9u5RYUgi7)>-$~v_2m2#P(7g5@ka<8F{DD6sl)zB_@Ml0n@ zLpN#pa%VU$v}UiA3PZGJuarFv(VD$djy1GM%XfjHyN1q5tdu9xX(;U~dB+ef-K*rE zhG^+tB}FGWd4EP}SIIC#w0p0TKNzCjdzG{rqTPFyTw!QBO1nxPFyx2UtK@Y&_Z63uMLGz z6R%2stCJT**f~|9;WZg;C>PKDHEA?7AEkXwPB(NvO8c5zW9W%7a}uw~%Z3V2 zhS%j&LuaDwuS?`KCxN>duSoNp(fst zwT8IE@rHbFh&vo_Na5*D-odDeH)OCO?svQ)`y1kZ#~adYi2EIH$fbt%K-u4rdkn2c z+24>?46Pe6C-H`~8~PYEu||6S(aGBfWnUv>4BdgUuaQZHxQDVv&M?G1lr?glA?~59 zk;e@^jIythb%wa3vPOP1#2uA2()A1{k2@-BWT>I5P}((epds$BtdSN&++SHEmmA{# z${M-f(8(z68hOo77uEA?LuGhIYb1ZBllMn0UzMS?TE6jycGrBz89G|?U1aD4jP`5f zDMQP^{D2~#;DL)&^M`_=bk~5vWBD|Y#%3g+sq3mmAnxR`!XKUp`Ltlf-TDjfO zkKnRaUNRI!+1JX)hK@(s*W#x#orKk>v$fLK(201`Yh|3FGx2QK%49=tDwj4x`=jh@ zf_ z-H)=rBgYupgtEUQ=NtMP+Wb56n4wyf;az##&|H-LUHONh3sClVrOUZa-dwc#cV#z2 z3s4j5Ws0F*D8qU=&(I9i#Co|UjZhQoWvQXrDEoT(tD(0vpPc6;G@|V5rPRua`p&9fq>67kH ze8UZO*YX`?s13EWUQRajCTeNDTxn*V2HcU>t&T8?mBOfO@_GZyg`04bWe0s ze52%E;BMDi|jnsKlE6Dc>uz4A|_*3je}qJs@_=lm0yVTe2DpU6dqxO4uAEHcEM^G{@j zp&8K+&%#d?m#fJ9*sU z|5^qa;tv1UvZo>L@P92w8rlUl__fS5#QpxSzssA3xZnSG`O?tN zsI$LI=n^M=JnHQ4QfcU9)Y&((i=i|7mnFWD;|vW&4Sp-<8VZ2tw{nA_T-4dOveeM| zsI%{6gCY7Cd?!B`qJP16l7Fd_PXB`MWQZa97kn>!8~Phc`@Kvt^mnwE@8!>i=zH*k z+-iuv2S3P4L-alPhkR&=z6bx1UkydT^B>anGABI&p8t>?4bd;*NBM)HuTa__t2@lr}Uibq_0Dp_(_Hv zYDAs=B>Nf4L!JF3#~S(!@7ParwxMr(mL-0Y+YRl6I{R6kGqfvO-Ouugp{G#Vf5~r# z?m(UWOG++x((gx|{Yz>MU5`5ZMGiJJ9(DGM%rJBy-r`?mo}mpW`>%4JAs_1OS6N{w z2W{|I`P$I^sKI|rL{E}-S#OA*B<=FEA$pRuOUYGE`oj^T5r#hRPc+fcb=Z+^ms1R#j2-EA zxysPmFwsMX=#SDaYYfpJrCq)?M1PcaDY)9nYtZryG(_)|cG=euy;Iud1Vi*rX_t!) zP1W+x#8=~Kg&&Q7&bF`q}jL&ze zq0y)bpYI++H^$JJD7)V`$>NhrJD_qd@=D1+a(&d|mb(T|3nh1Pyw*ZEG~sVKYOH`LHsx%jMz?-)bv zC_}(^zM)|#d%$ucyPwE3X#AVYt} za}W7WHZ&J?7V=$b=t$I3$oHV3>1gvI-$#a4q3mH_V1biBFQ~Au%n-ex!oIPF=miz_ z9bsrcls)V_%Mg8{!oCHD=o1z8J!6PIQDNUkLx-X4Vc#!?=phyMb-&3;r-xM7r$4EP zR!$G8u&=?=rl9O$-)V+!LTSUkd4~2yX~Vup43%rXwP{K%-}i>-XBGApE_CwfXBGAh zHbg(G@c+lteaF{yzi|L}&hwDz-sIj(Yj08`)QBAf)mF7y9h7R#+CQ`?AykPKL}-kb zm#i-V(DvICpe9y=2fA7~l-|zE%zUSO?&-s3zhSd@ZGWq=v@&v`Z z^H}pBt-%Vgc0pQ$6<}S2v<54{dI7y5*#=le=H-(A;sdoJE5ND;X+>6m)fUoFMn$w9XcsF1Y4J(IWiN$R<4Ckgxu0guvHAYDBtN|D;l~k-|1lM3uvF@ z9&8PTnn~`#)=!XDxCL8VAgyoAgzT9v93W{3m0P1FHQulg$uDtLNg`X5UUY%R?d5f^%WE+ zvlLF4TGtsxP^R zT0Nn+&zuk|A|QF6~~t%H)~n$K(9fSSroxUAeuoCt-b7nfBUN|xMRR%1x3`&?E> zNUQr?*7uNB_qnXOP%g>cW$lEt*3V^KfV9@nWj%+q*3V@XUg|_IF4?-Qx{y}>xvbA2 zt^9LYgCVW_b6GQ>2+7uEZG?g)TbK1K6lU5zf^M3T`O=(tJxqSppdu!}k09IR*AEIQ z8t-;l3!qOWTep=C9g$JHt;^8v2vx3SPJ}}d@ou+O6Ph^(Dt-ql5h2!1fR*98PyfB$FkJS)LlsWTQUqZ*^ynC!T zXn@R_$C?eblsWTQ+n~*oyT{6gdP(jc>nYSza`#vTRypxDNDdyW7PM1x@K`;d>M|2v zYaCQwX2NSNhMq|dUMmCICZqORH=y?z2ybKT4v7FNsZwL_1bn(MHuZdeoR0Q7sl7GdvL^>#X+)sFlwtd(`gXiBc*-l<{j zEZ(J`P096P!9`(TT0a|2&2=x|*09djzxeD*mE&REt(Lo;BY&-YE3A+8_8vzW1@eav zvfkXQ((35y;lr$!j-*<-p8Q3k*3jxQ4MiPZsbIbEkydwTW5I@^{?L@5dh*wbhCtJU z8j5~^3g)R7KFXQ^70=U9G|gy#F0IraZCx_@K3Ad0X5pi)@%waS$$<;%z8@ZMB}3Ci zW39PH`*Uf%_jt?RuSYty;Nz{rMp`HSY4`+doKbwPjLPl86Rc&9B-?J`iPj#!`h`!m zviuqx{-gE4uOGr^TAZN~;-&I^T=-8`Er?4@3jf*q!bmIMr-je4x>H62=AW{+s@D;3-szS&v~Z7jG)v>GZ>>p=JxYcEux zzUmBAwf<(&edv@Nxz*xBCVF6Yt<9pMP)PKF@NL$cP|I4GqIaQz^;MmqVzpI6q3CGU zkI+!lZUr>ow99~+n|7C=9;V$BD0g{{9(oufE21g}T`96zR2!NVaUguV^&T`YVza0# zG_CM~@Ez7*D6xE|C<&TeRFww3|AuNWRIG^VJk;5=`v>Y|+PRN7BP(gzML-V=Yr959 z&fRT?73+K^-;R^=*)$xf*Zgkl9HiHLy5-B%KJ}XaEqt$46mnI*7{1Rc0nILXF?_#O zA8KFfMtFwR6ABg`v}QuZ>fQ)HY-K=QOWp|2v>rjRCH@LOW|cka#OozGVSNTYFZNe> zmNf;+5uLUUL(}X26@JG04_YAl&5Amvz34UnZ+N!#t&wxhpR;B{dd;7=)kCM~4_B?O(3GH^ z;n%D_(Da}^qCt>;SFT&*A^omgw`N27UAb;;ghnjG8?LbPC9++_x6re64LMO9jh+1ypif-Xnmtl z&mC(B^itaWVNHedHc%~t@-+zc{9&Dh2E=5B-?i>QdM~(Zah4NZ?*(_QBG3~V*HsyBd-A{55U54rP|v^CTqsa_d17sb^gjQ@ zItl50{)u%5`b9?e#Nx9~^cA6@o+nm$NPh#KT8$z74R~s`gVsrer`7PVjJJf4@4X(RoOE8?;33sCDSr95Qc_p5?O{`G@wjgTDZs$9*J z+YW$cm#pT=V;6?nm#X6lu{oM7h}{zkmLv1p34S&9xa|dgHTQ(uTcBcf z>v+6&wqK1s`RoUNHTS$`2c2_9*0p3EPXW8UUyVJ5>_&by_Y|>PLxn2W@f5QM`_+Gp(7lWqPZRM$N z$N1IG)4*=)S7%Qn`&(#%9NE~O?N>WbQ+u0VojuL$Y$JVw=;e9G=8GE7d4hP?ego1c zh~{=ZNS`2@+y67tt1i>k-2TC@EZ2MXOusT*E$oAiE$D6Onp^9s7h^BBJ@GDY9%z$v^xr|HSO*~?M%DSD^B!S)2;&4!?b%BN;K`dL$yu2 zu~2K%E)_~P?e;_4OuOq)7t@ZfI`KxCcBP=)<#l$WpzI>5cc3dpGF>0pouF9}v7T1; zKxke>rmK~m2u&*->-pGT040_mA=(B_E~+{Wz5j;lPpDWCRlqf8WSvdB;!r2kt~ONC zwEGBpSXkTjgw~cHEgBE4DXLlm^?O5g0E#K1%7Mn1c6qKl(c?|KGEh^~t_hUiwCe;d zHSI=1OH8|YP&d3ylAUFW98bF%Gde`ln3sm9#VPIP2`8@t$NA^mOaYKPs@cKX}6$kWX(2(_+~=IL%% z^J}fAhy5Pps=U?H)BXUOU2?0Zm)#R;UnBy6K+5c0a!kdt&WGzp^~T?3Ga0lIfn|_IbY!d%m~-^DE2q zgI(y36R%L^bkAt}9ls8H#@OBb%JPh}$3U?qvON>*O@3YTOtgRZ>krRl`#-3c9680V z^oJ9nSBYz$srCnc{o(o1?gu?DmhG8tr}=fwGt>UnuRlCL+16dBmmE2Awq4h+Yn~Ll zt6zV3=GjxA>2 z|Msl3PZ>Ga;41qGq}SkT+xM6D>8$##w!bpctL~|1wLQQu%e&Sd?^lF-y}i({NcRSN zuV3>2EZg3D+Go7^-P~jsg7kNDlU@3i)B?{gvK?@bZxWOLi&5V-QExB@9lQ`5~RPkJM4#0 z$406=_nnNoH_CMFu!}?MYR7tZ+SQ<~wKH8i?G{kF^s>wT3OX2_>Dpxvfns7}J-h9R zkp80Ywts>27k#(A6UuC;It%IV`)>O_q`&XG?cfK_$d)%!m4encig53?-+^9AyL7uF zl(&H@7RuKk!kuoEzE$IgQEnPHFp2lPZnw#Ux>(1~tIyghaWNS`P6 z+D#yRp4e-D0o|5y?zIO(73!!aL)Gd;xcA!Yp?77R`|P98JQ>+OI|o`UBim=^`rC?_bf8Ciz?49YY|`W`uB(dVTMyBeg=OBwb@(1nJoevm#rW!Q<3K0RgFE1_eJR7at( zI;y)+>3Yk8~Li!ArVW&d+43=T~b)GkvB&GfngzP^yW39x7-0{0|yzq8Iwti9W_e zuLE5$N4A0V$uYzJ4$>#b40}41V`hE>q|cEV_Gw6;BQxy3p|fV3-X~7H0w#J@sFsQT zAvD=U?+fYkXNEl)(&x_%dj(Y5^qC3i(`bhM2c%D<8Ft81Cmx$V%R`@<=|H>soc>-vww$XHqwj&L8f zqoF|Q<(T~$q;F@(?7on`ogK5sLBGhzj@k2}6`>LCWA*_^-`tMdmmz&~J8nOP)=7lp zwpag&8HwIQMt0n;2=y-);XZDE1Qn2WC+wckxnR{OsFsZEggqN7DlUF5$t0ua>r)JreAbn5GvfqQshN^l(hsvqOLxT&emOvFk zR0kk^zs<69Abr2hvh(D2;{9&=ECcD=ZkF8y(zo3#yAyQA^f?lmW1`Q4>Y3=fpqIhg z?jqFIM1KK&ZlV{-<3yirqSu28nCNYx+osPUP@0K86Y5~1Z-T1Wx^p%bRh9+1BGXW8Q*eech*7ehx)pBd1PCi)Ginu#v|^p&}K{xP|ihCVXU z8$%zO=pCVHCi?eKn2A0Yx@`K~3C%arFF>tL^yg53IkK=6pz8PbkcnOwnrEVa4$U>u z2Sb0G=rf=%P4taW6_fj~kk1_X2-;+#`$C*}Uz_OFpaN!`A3+05^nTE{CVC=t!1TEi zs%D}eh1f*D3+*#~hK4%v5=`_8PqHmr zV3!k5*NHCLuS2>{bjdCQ={nIRyBhST)!%d3ZU{Y=wV=!P`;e|7U9sCjx`uSc?*8gX z`RuAa5YjcItM(`;%0|%*aJl=P*Z7l-ChR0FMVFOx4b%1#&W~XfJ#cAH|*0; zRp~Rwz5>;iK6C7Q(6i#jyf^I#Ddm(^qg<_Wm-XJVyFt@Mx9!DHaGtW>JN8ZJ%ZQ5J zKkP+ePP;E7qP&0F>y31sp^^82eb2~QXLxAm3)dr^x}d-9Dv+*q{cY#+=x5Fu``eB+ z(p87P{I~tHBeT}^ul+ZqYh6$5#$FR$*1DQ`pV{w2t*f-~{%800>l5#DI{|W4{=)mh zo(#<{`GxnTy#{Jusaehu&j(_W}p z-Ok=ndg)hhuZzm&bNVb+cYrsHT0mV(cJ_K`tY5voK3eJ50B?T!6)IG@v-fohHzjY$ zfrTpf_7VTH68XRWIV^)S+_?q_ck8sXPMZ!=2vYnk_5TJ6_b?|YQt*Lcqdl;hV{?}ua; z)(F~Xg1Z%!^ef5zF}>|qy!#VMb7a=YKBcvgu91C8+g?f5$UdXJkgkz^MwyVVk+r7N zkgk!nrVCKPJpDaw=mu0g&p^>%kglJ7PIeJ9&Olv1`<(JYx_!PpoKLu zT^(sVRKI4drxP8An$*m6b)wr)^LnwK&h#Ams9vV4Grd;K8Rz^4v7Rne9$L~M)76EV zKxJjly3%KmuKIPQzL2i^b)|8TuKIPQ`Os_CRa+rl1MEttAYB9ON_Qb$1MEt9iaTS` zHNdV^5qd1Wd`(TEm(?TOUsKOll0Li9C`i`_yU}b&*9W`N9!S>*yVE)7Obyi|=wgis zcXujO!ihep<_J#@st%2=r)mLBtQX<#LEk{F zgLFNz7d?k`J+c?&FX=?r^~hdS4{9vA_oh#v=8}7FihU)?y$?-;bgi-vEr)civJdTt zs!O(g=pv*mmwo78NLMcVP|;FObX~dpAJv3(74=(+gme{k05yYj6?Fi81$`wm zIDiI2-Q;>1Kq;>z^Er?8VcR672zI2^C6GSSuAaZ`pS8TrBl#%awW&oQ|Pdq zm7(N~aH6Nnc^FC+p+j;WhSEpSap`jy^@PqwN4SU46zCn9&p28NrO8=|qdib_na?;n z2epwIjH5@;Dmg20~VAt zYHG&muIR+mRrffGgml$Cj#@yv>K;eEph{7y2~bp2f;*13L)#?V;dBnll5B_5GpMSZ zmElydk`u3P!vyzmdJjsL+(%GPNZ0j8(0EAK^+(VWNZ0j8&;e+sB=>gPHX5xDa zuk6HoBDsH0RiFSF_4o7v6jndM{XKmTeJwLFlIB9`lKV*73DuC?N74o8Bk5%%J%^T< zc7>}r@%}UI>O#**+{6nzNkj>;(N3%x0$9z~NO-Cr3+DBQr*^Z{OP^x4*njS+hq|ecmzq%7I zK(6`G^fvU37!pu0QqR07i7 zop@>py)QY$)0dF$^~6&gqro_N{@HIS=3p0c5Rl5IRah0aN~@l+tniQYuc zQ9RXxQcQlILAqlUPXi&{F^Z=jA>A>Gr*+VLlV28;Rz1NTPq}M45p*|c43&m-H)#wt zhIBV+40VJiNVa3>dr0?|#?V|y_m;-cPDuBb#?S?*r(`>ZoMOaAr3C1(18}Mg*wShjHk}f zKAFMs^aIpI&iiit>-FT`G>0aG< zY6sPkQIDrsNO$hW(=14L?#9z*NO$hW(^;sb8RuiDg51l;(;M}ic)F`Mf!>C6S8oD+ z4e74l1d4}3p7jYsrfOHpeBDIG;lbM)E z!yw&LoJc=Ix~DjiwnDn6IFWvb-j>`a(i7;Y7Fu%1N{z(tXNFbRE)t%1Oixoe1kB_eoR=(p}3*6a(q58|A@ z8Uam{Y$s6)qI~(V@8)DmhH6Oe ziL@SCD07xbr=UNjmqdC9{Ug03lBcl~k0tj+dK2m;xhGOf=%~zDBK;5QBWF61CP72x z+$Pd;D9iM61ZpX{C(<3Lwd9^i!A+caaguu?y#bw;Y!m5isF)e&*HAe#&Uol8liwm} zyUA}av|pZ466qf(Kt?@<@-=nh1<0A6Le-%nGR`T~3M$(m!99gWL&GHZBw7gNNH0mW z2O2K9C(#w?N69^jthby9e@LH6R2=FcxhGKrXq4ohMD3xal6w*jgMuW7B>EZhNDfJK z6lx_iF_rGVlFYn}4gl`23Fq?f7mE>urOJ(apcbtU(yG!|-7C&4|Hwm=`r zsHf3y&=Sdg8vP5EmTP_*z4o>fZ>{7$jiR7KlKV9J1bRcx-!%FbnkczXqp8p;$$c8F zfi6hy)93`0BiH;iqIaAK(UQZDR1!**+<&A-&=|@6NBZj3XL8N|NJ-EfnTcdt4HcCf zlIb|qS7sua{(2>uiDYuU>%@zb+>@yiblS9g4{9U1CsR-8p5&fPk=pUN=>OpJGytIW{nf!)89ZY^Rp-pC%HbFJaIL|;@*^x}Q zA+794rst4Wb|lklEu83D*)g5ULt5D}oti+)17kfi=rd?tV5VyZ^@FtPVy?I@G!N}-d`_JXmVxpW=c zTQJi#mtI1%gJM1N$n$|S&V@mlu6a}uDl75kQv*orQRY)?Nb6DNQ$I-SQRdTls6&uy z0i@L_^JyET)hYAoG^Euj^XX4Wt5fEaucb4VzJVh=3#bzGU0{TJ0kwj(a%Ca)g0ymF zA&rK#a%CZle8MMNJu@iNMe@ccrBp)OL(@e9>}dKZdt|3Yn{ zhEmy)O5Z|n2S>P5X%@6P&j`0X{F3!D*cf&meMkV zOQ=3nF(|^lggQZ5MYEKKLRv+$lzxP?ie@RTfSSk*E~N}eYigF#B}i*(mQtQpPCTut zNuy$r*3_g?EvT`~SsHx^HJ3R{qe0NOGH1&u5gJ-H!o7?(K-Faim(wwbq|fDa6AF-w&Q?%4NNadjP(w&-cveswNNadj&~Qj=cvjL3C`YngNvom1s3;&~E5ksruMLm!MO5R4*Z|`PxE7+d9#;=4%Vp zhqUHv3$=q+qbZP9oNc33P$9{E8y$nR{%jll32FV=Hp<)HiKq2v+o&Q`RdU}(&7r!1 z3GQt)4AR=P?esIGwQ1XFE2On)+v#_xj?Bb%dIFV^+_%%~UpnzhO77dKCRCwNf_po4 zhn~u)chFd9sN}wbQlZL{`wrR<6_MO`&~>PvK)Vp z8YsE%pb^mUkOcP*S_8e19Cp$PsD|XelkP#;lEY4NcW~lelN@$ZBPdm7Vi$b{xulm} zG#t8FMl}cev!H4_bX0QRMdzTFlKU=t25GI|E-Kj3iKn%GyQns#wSK#(H56-l83bwN z-!4jqwDNBkt%tPoZx@|{ev&!cMGqmZ1KdTPPEI_n1KdS#LRts7i&{eSO@9A_(&U=o zMf0G`a^82-E+|pvY&TtmI?62VrWa68x#o9MmCjDQ!;*VCeE?~tVLJ7Jw9+u0CPG?i zm`=-}4clH!+7*XVOnwa@tz}H7_K?;xrqeJ;YZ=q&XDGccC{V+db4B(yGTjG#1jT$32t^Y1QK% z+7G=c+3un1kXN$ZL)^`YSGa6~dk;lH1tj;q^a*rdM!lE5g}#y8_tI3TtmM9z)<8bV zeJ`DWzLebe(mkkKXo7n$mF(`sJ1sfvqef6xgz773z2vZuhC{t2_kA=6dL%jQqwP>B z$$cN4gDRFwaPK2m4=2J&$zeZLg67Cf?5Fpj+|tW_>Iq$x@AQ5e4_%k<^nO|b?UUU1 z(*dZNrFGL3Z(U>8T0|9^`;rr2bw9_X3#|Ftep1@S_Z|*EM?GP zXpGzkGw3p?09X?^QKdJFnQX5t`qfwbE7AdP~w+VvnUfVA56Af-d?B=>`K z8JZ}$AEaFWbK)h-HGhz*LQQ2R4$+5DVd>=%^?h}uuC>BH>kK|dx*wBT3vgHeu1>Q_7LrZw7T{X zU4tSd+e1YCoOr>K?I9`&g_(AZpqpl7UqL-ge#4<6Ccin5ZSva=g%nM2AELjYPbJ&K zts$xgVjY&_T)J z2o?CoiT9)AeuQd4|49x<=rib7`A#39fzUbmP9LEkp>2};5n2aDn|4{y49WcnJ%FZ5 z?nfy6TPNNf$@U0Ufx4SsK7c-zb9;pPK;`60K0*^A+q7E-g~)yI2wi|i$ed-;b7-Yx zn@NQSIPpG_S<0lkQ2W9O?o8?pg~^;9r35HZ=Ikh?LC560AEiUk0GYF+bPH-Jb9R&h z2RacpOYTRh9Mns4KT2;wJtg;})CJlgIUJ=?&`!zWC~bhM%S;@j(@=StiDUFP^h|O% zM&3bAylpb-V^kG-PjWv-A3{SU_hZx-`a$mH$7nuOLV7t)yP<26!*RLcW%)KGFiPP3pW$^AHOh8oBBV{H|Q1DC^K=AHbJjRFDK~?^p?!TN%{wRBsrX< zd_$agn1xpq%d z-dHDI3(58*RfP7KaW;oGNwz1c2eiQCHx6oR@>>jDHRH^H!cBfRpj~F1xraLO5=?%j zp@(LijiF5WPM@TXP?mhBPty0$T9e;gsD^2`)5xh{KS|G>&!p}*-FuS4hB@(`6+hrT zMNJ?sk?uWB-Hr6GHDr3vP=CL&yk}_))Vj*=-rwkFzpi+Hr`3?F@@;Q6t%GKlyzMfZNWrixJ4lJ~t=sRdN1@_p}h>I21=c!`C_kLaOanXZ2*XoQ(v`7Fcrm`XX4I_g5!zf=KQUB=4wFI9h~f`zOn zR1ex%(8~3MnnFct1^J#*Yp6nfRZpmD{Vdm08V#M2BcIU>D7#jc>lv+qLZX9w|It3E zWi6NJ0yMC`>M>NTwkqs<=L(6AR=oiYHSJ=c`KDc4sJUtPE!4xbn+SEN<&|ERL1XHx z4ntLHt8POdM5}^EI?*SZb`j88)2n0D=iU{&Or{mDPh%DE0 zx(!V$9OQdJ&!NQfE>Zqb&d4SgRaJ)Ge?#>)RIG@q1Jv2H8v=DQ?WRH{O}k~#!@}Bb zAGEf-S9A?pQ&c5?W>^1jfxv!ms7gXHMO2NTF{a&DP`qh39BOLX&4KcpcH5z)rrkMc ziD~x?>So#%jCZ2fHtlLdai(2sXqag?2&!k=B|~{lyY)uSeeeYxb3T*rW&z3JCXUqm zpv6_jIPvs8Xmd9sy$=@mG57bYtS>iDf?Sm=`SS1-Xm-g;z5rehwJ%lO7s%OAuqc>= z#%ct;*H!n0a!sgf$?85AcYz93uI>xtsZeZ*`aTcug?fp6{0MqptiCTlS03l|nIn3g zyF=6K*7p_US!WDM)_F+}|ZV%m-aaQ9&P=z|GM5tPwNOv{f0KF^Ytj@=vc`~xn_=(p{Y!LQxX02DgC*$;fJOe`t8^NOujM0VT^gqj)uxU*bjaA*h>- zGm5W517&1U{2!EQj?991iFuY!&>QXPZB z>ZtyNDmPH&o#Mock5N^G^j9>Bn?w358pS=JLZ;7gkp8Ad@nT4SQ=>QodS;Hi0ktsE zb0<0R+M4L4p*1FYV`#I9-VwTIqJIxHGkwm5W}4_bp;Qz70#wfQ`5YQ-q8Fa(L?2_K z*M%;aBR_}qNg#>`L;55T#WSEBGxHlEeJ+UNUm<-ih~h`kSu;-GG$&pG6TKQ#%S8VO znrx!?gY##PlkG%=qsST=EzLwu!;T$WSc%iW;*fOnCRu9 zG!y+DXtO!88&uo$IR=_wqW=QTG|~4#_so&kAblE*;>VCajYe_sEGN1?jYe^4sB&nK zuO`=oqC&G=HTgqGpHypcXGot^YjGT;PpY+f3RFKtwGe6&lI5z+JD`VUgM4-P1oX6Q zma7imf#w$q^3~-R(2_z~uDbmCPtG`p1qb=+aRo@9h3jz>NS}r4@fXn5P}M+4pN#AA zWJsTk>+xdfL5ONM^fV;WU5|61)N(Fgea`i>6TPWC8Q13mP>aHm?)uyS3Y1==xizFu z+tJ()(x>fc9uNH@Ba7w*(2CGVcQhY_^hvw{UxD;Vya7Lh)=7j0?3?Y3rH72H0at?h zmy2{a;8supY1fc@LFa;1qoG$Gi<7~wLK-VKw?m15M zyAhG@MqC5Rka#itJ~URw8N**g(K5~$9`UMOA=ON1m5eNg*FgHF5yK}Sebb2Hdr+Rd zDtC$#UEe!mI1tMl?_$(f)15aO@Ia$RxO1pgs2We`o0pwHz9priQ#~`PQ2eu zpJgF^dx_zukiNadaA)X@>GKC@j)^`Ws%N6_hF%71yGu}86a6LhxrttMo)dkxiC!Nn zV4}B!Zks-1p)?bH7SzE+-wcJBBhNxvCi-JY-??Hq|9mH&zH`NJ4d}Uv{xPI)U@`m+ zq;Fs`oCFmyN3Mo;o9M@(z9#x#P)RdR*8(S=zPZJ4B}m`gV)#Aigz2*?y2noS%|t&2v5EdCw9oXJH`R%kV4_!q%A4rT zp}Qvc9?&}``Z(xq6MZo>!9>r10!;K9&{@-G?!`{LnI?K^=mQhIG4!I4ULhT!-6s0? zM$UVG49|8xlXuw9<;ZO~Qs4XEV&4)cg1+}R<4#8U-rv#p4)^h^yYF3|0=X*p^EKyb z(Cm`^eDCo_sC}s+z83rd3Ko69m6mD*eeWOQ`;faqT}uw}wc-U(p~^#ipYR1JwnV(| zGcJ%^Cg zocI3DoO`*pbN&WO7cLFydw&;>Gt&3|8NMz&&9Aw>ZoJg5#lD`r&##rf-kj~%Mqgik z=GRVNf6l)`<7uBs?r%BDuLSo1{@ky4_dt$!WY$dw@gzvs3kLD@SCaLD?>Gh0^@8ts zF{JASgLxIC>ji^(GgL57A!`WlhKlF0at+}_kgg@f@_9(t5@Putq-zPW9K6!x5U6Vj zL%9f~YY9WSCUm}9kZ%~j3tg+0DFAzf#P<3W(FGsN)}NY@#L^Fm118HV#lNY@#L z^9g8s^&sB}z6R~Bp5+?BFCblm_@2X8Ipfqdi0`=qq-zi(IU3S6h>_eH(lv-5xF2z1J8i!*9`KF;xwp9%`Dd_-Ul_W7vvkwXQ7YkWw}Q4189DOAYVM^UhRx* zNrNm`JQst?%AAeis*tXLjN#^xu7Hf;&XBHvjNxI>Yt>cBkgkV};gyiChm7F^kgkV} z;meS&hm7HXHO^QbOD|)&IP|i5qNs?GDlr_gW|V=z6LMXkxuc_XK_)Iw$cG_-p8z#7p3bP@8Hl-$ece z(zTh1yc5#3nTdQB(zTh1d>_)anTh<`IwyK#$$b)6ftpM1llaqDlH4cr{~%qrnapD$ zUALLc^PuXI?PT5p=_<}-&VqCmXEHy5bQLF&J?ow5x{8y?6(C=fstHs$D$4FUdUQm1I7r^HfMzo2K(JNLQPt^FHW9nZfCN0lFk}Hk}_sH)YPIbCFHX zSpJkbo54}gKXTR0;C9fbGJ`XD0JK`>Y$i{FO3R$hmzHapSpNS{A(G;~G!{E0t2 z9hS2)oBxE;{5HNX^Z2Z_g+GJtnRWxAu{HIxAE7z* zRO_Herk5YNI0i>&pDJ=g;o<D>MCOjSAleuF@-;X+L`?NypqXpBBX1MDZC8Q zHOCY_3^g_5ybb9JWC{oEaN_9-WC}+>x&oQP&7ewAs;*E}RFXS|XF%H|+qt|E%93p7 z@~==;IV*Gd5mdKfl6x+f+v&thmfYv@Tad1C&f_kSu5r%eQIM{2&f^8pOv!y7r@xZq zK94U$y5c#HbM0~>=!)k&E(Q&j+~;vLG_ppLdmi_F^_k4Xe4Y&HYUq4k0qJV!e9nYA z%1q4XKcFX)`+N@B?ZgX^QP1b{P+0vW_k3;#eJwMwfMcO_$$bIOf@(>ageSxFXF|Jt~D>>45)w^ z=Z#mAGrfp&?{nhm%JU*F4e84BB5n*lG3`1+wp{azcp5ZCvi*hELaCDNFMJYuA$|VB z_n`o}=6~T*`<-~-Nbac|1L@j!DtCZ%Z9A1mK)SY_$|+D6$vu^KyprUe%I6_n=}zVU zAYJKB;>LG0%c@oqaKHhI&f2i}@^M%cvLgV<@Cn zl6x_~ama~RKyp~ZZ$o`0_a*!_bXam)8Rto;g51l~n2$OUbcbUZmx6SM zV;RRly2G)IJ3t|F-k0$RXqIHVj8mXhlI=3y0d20AU6bWJ0qP^UFXyGuAi3t3^SM`_$xN)^XOQlwtl)yjop`#VvVv*{tMwknY*6N*{tM?&^pO|CBJ}l2WKT0IqAgH9h{Y1 z57Hf+mE0DZCfTm!A&~CptmK)H?&qxJO_1*AtmHG$x03Bj{s;1zapuc%;+2$hw34es zgH3*|pi?Hl{!la1ZVJ@dv|9!BmS6jod$&_ZcT%5gF$ij)clKNOG^?ub^R)`&u3j zSta$n1}p{0`hT5b&mNe*jy z5af{@*76dlmCVFCKJZF16YDq!>Mt{~j`RHHM0g;*tm86JJsI^nZUWVn+}Cj@s7ak9 z_d5O&`bb8-p4UN3B=_~41(lX-emy^c)=KW{IsA7g-XY0-Jy(I=kn^{mKY%7m?(4Y^ zv`TVc&l8~wlKXmI2Ia^#zn(8Z(UQXkehwu{?i;vpwi9oRn`^uE(R?R4DxN}s?fT?EZ1g!57Hg* zE&L^nJm{XUJh+9 z806c{yP&-Vvs}CRcW8D{kT0F@LJNbkT0+dnK<;&p5P#4+P&fu`CPW0Y| zBHbAr1vQji?gRV*^mcHh`v4DsR_Af~4)P>Os|F5oDx_5d2YDByRRah4H%O}n4)RN= zw9Mcke*Kygy<$+L`w&M%T2XMAKZCTQ;4t@vw4&fJkAs@X3?AnBkk%I*=B<#{7aZox zkk%I*;ir(+7aU>lbtigbnX@BY5o#`Tc7#8IzLh!4lu!5bx7+Oj`8P^)-xREZ=f8>_Bba%f64W7 zoYz2F>u`b(Lt5)_g6~6G>u{2TbDZc}>u{1wK>^a|Nv;RErO%Vx5z;z{ERKclO14=% z4bnP@EM5-1CNr4D`=R?~R2QKqWg^{KocpE|{c%B;?-Un>UKWgWpW-Ia8M!7-^A}JX znX}V85DJw!JI#}!Tk;({&5NP?MIzm&`6yIh=ImF_ftt!y_bcbUhJ=o8zHNfT07$E0vUwV$)iBw-7W&%ccM{Utm~6feX>Ck4hyCfq z)7qG9t_<}t`MnPf3P^Hi^9X3MWP6TNpwA@RbG!qJlwQv9d8lS^lKULH|8gQMmfX*C zB&5|h=eY%>)i>w47o^oU=XnBDNOC{VOChbrInM_nt;IRdHzBRXInM$2oOo3w_w!s9 zsvDT(KF_Tot<$-{gCMQbxxmSg*6Cc}^-vv|i3@xRDkHgH;D=C2$^8O*?mO`+6iRYm z;3m*h8TCc(1PztkFY-vJvgCe|=Rrjz_lvv>>LAcufX+pziac=Q?T}Gl zWO=DLte zdb!M>LpRH)219=qRLy{nO753=Bh*rIzs$cvTK9CBA3<98beVmBJMpyc=`vS?Vofg} zL0ScMnfpOn1$CJdA+3VC%qyXvWX>-0QAlf|F7sVTYoac5=p!ed)jsv^H(_JA1A_L$^9yqhqUVID!&71)zww* z25HsRRUQL$HDA{_2hy6aYn5*x-X-?&L2SENbc9U4^&oi zzs?gOpX7d>mqA}j?$`M+)Gaj0eVv~}rzM9QT=gEvAI%O$yQ@Ga=1vH%fXq?Pa4sU^y+@J zlUcgSzd&Q;K6sPQLTzNuZt-I%U2?z0`CmHmw4U!4*MPL1?-qXyX+7U9{syWqx!>X> z=qvx+=K%dA=+~$UmR{q`QFCne`yUlTsR{q`Q z*-$&l{WfodCQ9zNIU7orYyP(UgKAE^rZN+ExFS?odbz{Rp=8PZ4)=hxn(z*fgS49P z4ljnZn(z*1K)EFMJA4Du+QK`W+jinpkT@N4nK#&{?FchfXPu@3*WEFy~@lc?P?{Ba?S!JkXnF= z!X{@yla0aT97_mfkqiP6Eua8lV+28U=6(Sw7}>H1cF2cm2Jv9HJ(GEAmeeJ>ZXFMgD!F^Zc=|$k!Lr{e9+-VMTr{(I5QJ^oslz zM9=x3=@t2dh;H-8z9N4X(NxL%CDDcc*jMBqBD%mI`-=QqL~r_|U6JotO!v31R2fRN zt-o(q?4`R@~DCGST>9sT3riu__C?bqz>{8L2N`=fn3{}ItPewE(NHU{0&4y{YtFNKSJ~u ze+(=0Zxh|-XT37txh~z`41eq^^TUab@yEV0zbVmd|5&~_niU*+BW0-~4wF}#~! zLbR1X_ILBo5S{Fg{oVX0L~HwFe>dN~L%P3u$(ulQra$&~^V<@|{@CBmA5OHEKlXR? z^NFVUbN=1@?}$$FEAd|b6{3&)F}#=0cTD&2fj{Tp%l9Mt+@JIB<+mYv#2>?|{9#0A z`;}OgKbNT4S6P+6iD(1A607o$5q;o~VO4%5(IS5gtMXktrTgpck9}2sBvHm6`>OnA zL?y}FpXddDAFaxtPPCgp_Eq_7h#LH{ugX6_^oc)rSLI(P+RPvAs(h()y1#p5o&$*% z`J-Kx{|?cWa(sId?Ig!Ho9HE(=M_Y2$no7pWM!U?=nOf&FNjvjJbQIX_x_OonO>D& zhv;$tGrcOmGtsZ)_>Lu7Q}QmBDE<8Ps{A5}&R0!it}3s}KS;E=^1Fuj^G^}|VeCT< zALiHY8uoX-y19ON!$<%V40jB*;iS$VsmScvM=sCC3&8afpw5*<^?H+C)D zL-b^}Yhy!UX*KFuZYn%MWU@_-&4uTy(NDXy6ka2GKHIagwXm`pJyhRT_=M;?x&Dnk z3%Q1L@88K?Rjw2|6J3xS-q@$my&7#;KcLW$Xh?3&#({+q)#&3cLkbg!PR*^;IJB^C zHCnst$iiksGjba=jw)_v2ao~+O2Wz z!Wl$cf8{c_c`KUr;HCj?Wy3ns0&8$DBFs2&K$Q@glLbPVzrJ3UjGpf-a^CuLxsYa8_ zx^O}@y1RT*VNo^Ov;O2lm)3OeYx-}knNyfhjm9_3EgV*jj%hfp@F3CMYc0*3QFyi* z{W1UJ!pqfYa{25+=N@UzyVttAd`@AbYP4tl{KBQx=-vAB3-?u{@eLOi@@=d0j%m2K zup`lo0TXiz3j0>0iyAL0994}LHeONqQ8l{0@v6f4)#%p7s|#0Eqq`b^QMkDpEp7Z| z;ht*rWaEv6WkeS=Ez8_oc%vGPEC0ICp=Ua?3!0Xce^VG%jb_&0QkX%sr=RDd!Zy|D zJGmu=U8>PVjdv6dtwswQ?<&l#M%OpqTezeez1nzR;ihWzZsUW6`-pb;dw8huXf^sy z?$N^2)##$e#|v*)qlJxsDCByjv)+Bc^^H##x>uuD8=otTs7CKLzED_~XwrbCnLib_ zsz#H`FBguhMt7HAEi9@=d)B{R=+-;kL!+PBp9^bOql+3>6i%*23maD!mR6(d8&?(T zD{0=HV?JyAurRtB<(ob(>{N|9HGNu`Pqf9zyUU*yJ|dbkprPsWg6fl2nKPiZ>5D>L zHL5hJVwq@@-VIHeV()6y+LSF0uSS)oTyYXnzxvM_^TiFTQNF2I+@>0JYKn?`5dENa zT)9K>_-eGI+_88;HJVx9sd#fW8ramiIH_+s&s#@LE_W$zU5)N8cPk!5bYs(c<#O>R zqHBh&S8ga;qMiCQG&L0;sYb0$&Ba%$QKhMSG21WQ!~Dq;b1lWL)o4^xTd`7&CN}jh zj;Th|oB9^ltwx(P4JhtZjb3gTTs*8AZQV4icqY-*0lPGfEM8TO_HG(myt^75+_YBl zg=%zM)8yi3MDI2HsA+2P{r>4ZA0Bgd)AZut0crH+=u4Y6D6U_Pe$li^aqDXI>!!_$ zdlGFt_M*nki`Q18g^gPk-zOU0=gy{Wi(3y&YYy*of74FIXwd5D4^2t&wrccp(_Y1c z2B+J;J9w9-9~2i>qxYH)D85BBeej)4hZNTyl8&4{_zz7-7Oy6HMlH)6SG<*IjDPk$ zzW4&sJH3`=PAI-tjT)LxEOr^1R(YpaYm+XHA{sO1^QKwF2}B2u$~B)8qXB2-*)N9=2@|nd)s?pu$vx=QYq&0hu+p~UNad)B# zW5<=xEuL77mXzlge^!lV)}L2=hG_l1@Bfnw94IUZISy~u|hO#N<;I{i({)%Yx6b58P%xL z{EOlNL<=en&DR!nHEM0Xu6S`Zsx<$ycw04ZVDt6GKUSmR%{LS~j!E}EY|7Z?8;ctd zEvO7^zNxrRH5%UhtKxapXl(P%#Yd}o6Ptfse5D#qZT?L$J9c%=^_y=g4kB7mnb^Fj zxOO#~+Pt{9Lp55z`PSkIM86ufY4eieoNBaX^Bu+8s?p5myNcb$rF;0*sP8x5TRev7 zMgJk#`--|69oqaracMO=uKD-H?&H(E7b~YUKUBQB8vVHWkz%~&>TMS^KURFI8ky$D zi}NR>+pcR}mU*J6CZ^F!e+~VI;_yjn)YR14^knhmsjH)grl*Q)O%KsLH7!4`{B&{q zYP6*MO!1g%G_(HM;sT;YElbMJ74N7ibosj>r_%YG#owsg!t9bGz>Bu_<-qZX}@%kAdI#-=E zx~{OYc$-A$t5Q=#)4Ro^HcLlN>fF%uUh$CcrO{^fOEasAbBWFzyfpJcv0?LcTSK3v znU9KF6AkvO^ht5=)!TgZY4K#D6Z^M}I9=h!*#snERr*G0}B?Teh?_ z(LFtvX7Z&4L{Z;~xnk)RqFHNA%+;0NBRZmSVy;uE`<5I-d0D1wX&}+}{dID;(mF(Q zMlH*%QQDBm`PWY6(hrDwHZIH5mu3+S_fbRXLZX}e>#D}mBBJpF?`&!=Iie%FJlNb; z8opJ!_YeEu+0?hRfT%Ly&Za@7`mNJ#H~Co)FZCq4y?$bDRB1fXE~6&q#+KG2dVa|A z<~2(P6MeA8^5%)9IYdu)Ufw*VbUD!7|Bk(*0dLd3p2tr5@X+ zQ6HbTVQC`Ki({8JZ&G@i=&aV|&6|}zBbwWHdGqF_Ew)SZuI|0Od8^X?L~D*--n?yT zF42|s%bRy79k+ct^1J<)H}729eup&rXzY^muBGjFOe5EENjWKc+J;9a_49 zsQZNHn-42}Ow@Z!&+-wa%r0r(vd)vsN0#~$wfgAj(t1Q^wv8(vTiTxJk;>%qai#l- z-mhP;d_qa>npUwx$CY(y2cofkCzofHP9plnT9eDOOV<*8?q3a_QtFnZdE@+- zL7y0-}uvjB1)&T0}IzX3wO+LLJ4%G=H7 zluR{RuROo>9MJ)l51Y>~ZM1uucbb1s=O?A7h~8`ZOM@w$yhpli;OJeNt}ImyqB|2a2)G(?0y`}uV>9(C3 z*6)6QY5x9c-i?i$cYmm~oM?fM9x09fVY+Sd+BV9AvebVRGO;4A0mS~=Ot!a4kbEUaNH}yHL`yWe_4@yVwH{!JJFP1i! zXr9`<%W2(TD$QeC+u#ejzf!uIZIilO(EYVi<=`}LTc7tv=^&!-H7)G^W=S8CZu?Q6 z8@s<#8hlI|%^3J#^U6}UV?#7geKB%T_YX@qN_4(@uX0ECk4kqE_3Ls+_s>d?N_4K; zvFAhGGtsEyLf*ORoZeTJOHm@xJoV%HXS+wyNS*HC$?UjtmuRhO^mu*OXlgZjvwOE_ zgKG3%_cfv|h`y8ixO+L;u^OqChG>s!w4~e=?N^NkHZ(_vR->q;dvt6y>e|v0%_X`Z z*Vxh;ol}i^we*Y@RHH#HmFTK!G^(XeH(^G zk5!|MTZTnXSEJ2ahDWaxJ)iw^{fKB~HJaTxGWxh0ZQC*`QnR>^ax+^-M|IU`ua>b< zxf+dV7$3D(qthDKjQUoiAGWL&4JEoXcUa4WXnZv~p=EM3wHnQBStr`C8qI5&7Hv_D ze$ujTv|}~8s%8CXk7{&q!v@iQ)o5PhhS8zb=$9=UMaL4E?6qAsj%HV*-?VHJonDP@ zZ`m|DmuN`t-j*5Bf@-w1<-5^U)#&psn@87Gqvb7IM8B>^&$etC-A;6B?oTaSMfX*s zH(R!i9;-(0x9ku-Q;j}r*(rLt8Wmc1iB?pjF0D!QVKrK}>+aF#)u^d;kEnQ3dLNyd z>)EkDbtMWj z+r0JAXd8*nSJ8xRS`Ul%NVh4|w0Y~{(NRP?j?|p6_c?0oQTw$X6Lle4nmx9BT-2u;9o%|cG_e{T)p|m-HPNts z$Ch<8n`m-Q`)ENmn$@bKYpT)Q)>+ZLX(TH@J9@4fomT!)wBC$p=+{K|mcqz;iAJvdaOu?OK zjM(3ATYql45~qxq*LqrX8_{+n=e3?5jXyQrHm%=unTr}Rd}-@tQMVt5Xr7whx}x=`QMa?wsIiM}y)K%2b{g&3(51((qR)u_(xtV> z;;8N1bldXGvdrz#D54pCt}5RVtxq(*WB~m6mAj*3(y05RLsoRTCpt-@ zS^kLn^|&XxP@*}}6TOD__-$k)njIZJe5{YImgs!dxjex~Tb~zd&h~j5_P8%PIE|vc z25;Wu!RTnB->uan^I$YfqB+rCV?uOB8mTMBZtF)bCwiqav&ZkFmxz+Fd-ZrIdY@>= z%Kkkbj>eoH_BTge()-9Bk4762T{m`Ck7dz9qUCE0@3B1E{=#(ReJ!W@=)#LbG)tW~ z^70;ki0XclM!y~T^Bzw{%|yHOzoEy|QB1U9|66)I6FG^(JzZ4KMl%+Kyz|u^O}59g z(dL&*6~AqHj~Alm%R)3KdUx2XJzj}=6WuS-Afn4gzT>xzBI-CijGRC;cUagqjp&47 zANy!SiDpN?pPXxZCHfxG(~~>+Xgi{p$AoBCqFH`*Uy1ffBlXv{y83NLNp!y2sHvgt z)kvE#&vVtS4b$6JL@Onlr)G8DpzZCb;qtI;p8C_UP21jyj*{qHwNL#vZ7ZYYSESoc zY2ByogQ)YBY4q8kgW5ih`bl)Ix_aypZJ$OvNEFs=cH3vs+%$>~Zt#CiDmt6!QGZT+ z9$hF=IO|Sr`#f67w&wB$ZMnLSiRSveT%B4N_BSW`^U!yCqGpI^UmlSNdpeqE{+cw&m+KCrbRbLfxK3m-N1-typ(F z(TGoM5i>~+18NfI!(DN*QM?{iPA@gE_I93ZT@JV zXzNns}@L)07^Wa^2fR;TXzwAEuE%>t1Lp*A;%6uKZ8?uV`zi>n73p z(Uz5u+nVdTr;#d`b3L2uCZ;3hC%mQ3Pf}@her3||4n14y4*I#Qr=Lqv^{G2bqS;usd5?ZlGbi75)v>NW(eE-Z*T2vaHHmdGzqAjy8w2i6jbWOVQ z<=hKx!uOiK-Pl z_j8$E_Zrb_K3cC%{UY7F@^kr4T?^5BKH9Ku3ek>!e;e0rO?0J?Hm&<1(J~)xR(B!M zyFU6}-Hk-u{NA^ydyZ(7kG87&oM=tI_igHiT$}E_&acaMb-NPv^?To;ZVpk_*W9UY z5z!BPG_&sF>(abmL}ml)&0ILs4aRcRa763RH~#B z-xH~YzRjpNM}+Z54-VtQn7oWBsQdedt*o+o&Jv;#Wx+@zo!3h z*D$Y+UF%=HlHp9tsbgfnU#@MAnWR0N$<{Add-c||C(RA(pCxOujQ`iH;eYn>pB4Bw zT7h!-DcIUSs^`>M{`n}cMs^O5{D=BBqmuri-K9?$6K;QX%xS)-sP^yOty@Vw?d$xX zM~3H}@Dm!TL(5rJryd&7ty>3mr++T{M{0DTgQL0f>LRPPU=)Sip9Oc@xJaK z6LMY(vYJ@c@`85g07_#bQ-8zu_dSl4F zqf5vQTl4A)Ke8Zoic+T}l_SZjlPrJP=>N;8dnzG!6L|(qALYV+b22{f$7g+=kNk6V zMzx>e(XyBH-fX`$oqz4tm;AnRGM~IUd_b6Cc+@SbyZx0wNuB8T5~-*B^LTo685PE7 zWou4G=G8|2j4H^;q8v*`-8wXkN#{JWbGL4JIp4l|g;4u*@&2H2EZ=mUkX{K)8JzWF zZXFu_O~K+`SwH8IowI&b!)5nHAUg~F?Qy=>^Lz{U`c%_13Ze9?z*|{=Unr z`+S>``{LVO&!n^crWyWs_y1#8MCqB6K8JprtDt|MV@zlMzxO)o%a#8>U3u326#UPw z|9|&6{J*jOKlXE1d!KgokLOwS2jAwDe|qtcJO%%ct}^O&=?`BH@Xe2keaD5ZN6Is1 z?M%KryT0A4o7(yO=N$WYI}h96v;TV^_iE3y+A}}=v&y`j{{=b!i^@NN`>Tts{7*ro z2KlXZD)v3KKNmUmvh>u}{PwqNe|FNJptOB;dbe&_wWhqo@YSEAaFvqwx4%ced=C9~ zuY%jp^WW{P`~UNNzUlt`&-)DR&)fg>{AUIJ-WB-rarN)N`c3!s@9^kU`zZ0Rc^A5N zo&VidAiYoj-?%dQ^88G%pa1^n^ncHL=Rfb3*Zu_k=k|YA;GbWCyXAFy`W|ENN_d45 z9zDML&f%AjCtuyVzW*KQ>XBdOu71DqUU`3VfPZ!G-?8++^UbKg?a8Yj`S)N8>I47# z*`j*CPZ*O()^Gi+aw>e~Tl=28+ELyC{$<}U_Z}V@R=>|sJKJ=9{;QtV?=bxB$ge(f ze*M<=d;GtbRr~7lo8DKc9rMq;0=;oqSiSW14nI-2Z&f*PSQt`IFxNR!x4RagD#7QU6mt>DgPm_y6vm z@LQhMpE>z9x5K@em3uSCXH@>Np1=3JO4s4PZof^sQ!|E*kBIsa&%QMI?j_-xJN zRaT{sVD0OqpY6H56wda?<=N_A*;9MYggNAy!~d;*<$n*DRfqY{IOWv#{y8+GPWQL7 z>Og-xr^42}`ibxHzq^#J!{lcw>^{HFeIKhO5(*nd|~?epn3-Tt~q&cAm)wa@0? zbo-lrivQky{WIf#Am{cte;(#kPxhqLRmS-!e>0ftiGw|8F?%X z|7>9OnE&^BhII-@SCF5h-|)(M_3P+u{=ffrsFqd7`IXG6@JX<|TCe(O_SKn$&-LZybA5gd{WV)rg+JGk-gg<*=vO|g z&XpAl@6UYoEc&mvhR^KzD|3H__{Uyl zzZdjPzm-Ve&-wQ6)u-dbwavfkv)1ir{_W@dubl1wmU-5$N%*AS>fdPW>Cf}5+E*UI z(ns&Kt-Z#pybmI^!D9yg|Mc7eDI(Dl-uolYPZ(jZa;FTLa1|@Z*xlf zHm@G_ZAR5*g;!n~9vRZPrES{(?LIXcDm=VK*w^pnap@U^?aLC+sM!RiT*LIpu+f~3gb&^tUQu@`;p&fzu#ueWdCn)vTBsP zp8azEtB>waEn)tl&8jE-y|((@kMtaP!#^Ko`4>rV^a`Jf*-PH%43E-T`S&t8b-1km zOY+zo+N>(pTnTJBAoO&VtYhSnExn>nN7nA;Ia!~yKfTgU+jLCG&8T!fi~S1c)Ej<$ zUagcYC6C)r`n}}T&C=h)_Y~BH^6a)y_MXmA%b6L*=TsP;>BDpbg@=IMHVD))Z4r}AnW zdHoXhl~v1Y);t{(wx<92PjSnA`b}r-LSL;YXLw1@@JJ1oYF{32My0nemYJYHxgNjqKIjiuc}+8w0bP1=2>Jy_bKecPx`^=%LTua`s3i=@5Lw|&&pa{EQ! zc2l3o?aQS-LrolG)Qy>EyDU`m)yRQGwLtan-6Jz!+B5u^-kCF0=f(lPJ+uFS%mQ^* zd8FTZR(XNcjhraa;a~-`tQ-2?|YuiER?-pEA2uVbFK70oe9VIqHnvYaJ!p& zBh%QpVfNk3W}~*rhTFTz?L*}DiE{hg>?fIfr-lfzyesphA9I`Zho0}`>aw>Do#ET#{rB13EJuBttiWw@`(ByhQfZe< ze`rt4-7E92%XS^>bNy_KsxDJ3|H|JkmT%21mhWbGPR$?vXzn?+Q~fi(9Xi70KJnkx z@m8)SyHV3$a(%MD9i7jw;d5JkduE~H+dumDE$3X~n2~!dbDRHOf*kr?f*XYl*2ZtP89Fqg&HhBcBOp)B-iUewDu++Ryy$&q`rd zOJ(NEGK+nkqUw~H=>KJTgCD<3)Ry_>kb|TCnJ-4qjfQ7BO$qI*E$2lWXKx<+aA}L| zh^C?%pEr0&-vOA2OT(`fBKTO6SC-d1Md&-FG>yDFIoh)S4BR`0=`%BrPb zn9m3O#&z=lP|Sb-!^RzhlhxW4e_7(68|!)n>s49op~i^))MRm(`mQ)moi9#N3&r)+ zjpC+ixww^Dqj#vYljgnDuiK?=me+S}9HK?)&&PIQs=bz^f=)Ioz7d}tpOWrU( zmrL!3>){?a8&CHB#OE7tsBj9uRp{?We=zjN^sh&MF!U$%YczPJH_Y1--$U$(U-I_# z`ToUbIuCCcUm=b$L5&72I`seSB3*BcF+q(6Ejq0D-#Uc#Pf(*liw^xso$e1~Oi-gi ziw^w{d76(gCaBS%MTh?4InBoy6SV03>#J~F;kU!94d;D@IIc0A2dZm2E=G+O9ag%f z;}SGz(O;g+{`z>s{TdTbC)UIUEjsikS9*LHqeg=c{exvX9%Br@{}21$4K@9Q-*!sZ z17lp@8|o&+8Vy=>sD@DoV@z;QuN)Wg$zJ)nCSFKvi5-S_gTr}H!59s78^G;T_vBp9D1;wCFG%&G<2?y2fx_V~A~ya$Pen z9Ur4ci)wtjy@ClEbgQ?oNgdQ^QLV*xOwgdi%7k=Wf*K84bf_jWKa5eMMTeD1%mXzV zbXb|pI80EZMKy)}V~h!EwCGT+O?}j8(4ks~`k0_bi~cR(^!&pZH5#<&P)%bVs4@JO zIGleL9aiGBKS7Na9ag4O7d2XR=-;+Z=Z!HYsL`NBhiX0YF~$Tn8noz8txrD2n4m_3 z79FbZkdHAYsL`NBhm{S|{1_87XwjkCko{qd32HRxP;EpW#+aZ+iw@PssJ3K(n4m_379Cc$A`cVPXwafVwKem^ z7!%ZJ(4xc2HtZJ@)M(J6+Lrxcj0tKq=umA(9>$oUMuQd|R<>uJn4m_379FY`*e}MI zphklh9jYD4#~2gTXwad5^E7?j#soDQwCK>k0iWh!j0tKqXwjjXNj}Dyphk-h)h=mX zj0tKqXwjkCmHlCi8Vy=>s1oupL5&72I#j!nhcPB-(4xc2?i?Q`sL`NBhiVV@hcPCo z(V#_#YESYp#soDQwCGUnMLx!uphklZD|@GT2^zGh_DQ$LsL`Us%J->*1|6z>nIFcO zphklh)eqP|CTP&2+K>8}ph1fc)&A5)jRq|`R6k@qCaBS(I)Hg#f*K7vR0mQQH5#<2 z4x%o`n4m_B>R`rWj0qZas19Ksn4m$6>QKgEf*K84bf^wvJ{V(y8Vy=>s17F|V@yz^ zL5mL65#(cx32HQG(V;q$e2g(cgBBfDj!N@lOi-giiw@P%><<&vXwafVbqxE%1T`A8 z=ujQY{xC+31}&=N7>_X~sL`NBhw6CpF~$Tn8noz8oj^V&sL`N9bt2<2#soDQwCGT2 z@-fB)H5#<&(EoXd^!XZNOwgc3hw3EqFvbKm8g%G?>6`8kV@yz^MThES@-W5(4O(=l zP9YCtOi-giiw^yt%}D2qF(zoxqC@o~@-Rk?1}!>NbIHR5H5#<&(0`CCohQbaph1fc z)oJ8mj0tKqXwjknr9!%2j4?rt1}!>NXONExYBcCjoymBNF+q(6Ejm;`CLd!=P@_SM z4%Jy{evAq3>s>dzPfcvmp*oxWVvGrDG-%O(G%sCWj4?rt1|6z%$io;D)M(J6!^*iF zCnjjnqC@`?t#qCkV}cqDT6Cz+BOhZ-P@_SM4*egPN%xO2CaBS%MThZ);rH_X{qu{` z-;>3dphklh9jc4@JsifEphklh9r_P#sMG!VW4z&gIekg0sWHsY61y71`@EG))A0!! zw5Tpix5t>EMuQd|DwF0_FvbKmT2z-a9%D>Uqd|)f)fF5E#tVsS4C|nYO^vb+S1}$9 zI#fSRx5uc_qQlD1sDlPAI`kg}OV2xuF+qbC)z2A^F(zoxqC<5J^Tq@<8g%F ze@sxLL5J!_#$$pS4O*<+#Jp}Huf}j*Cd9f%`8~`c#-m1q4l9cphY4ylXwjj%m3d)| z32HQG(V<#GKE{}!MuQd|s@urN7!x$;Fnn=xIL|5=V}cqDT6C!HARl8)P@_SM4%MCH zV~h!EG-%ObV$*zs4l8%jkG95eemG)vcRDT@&X0sRxi9V4HHJJxY-2Iv7-NDO4O&!7$-@LS8nmb$Vm!u}ph1fc)x*pm6EtYip?ZY< zV2lZBG-%PG|Cn!jzG93CYBXrkp?Zvbj4?rt1}!>N%gDzV6VzzXqC@pK`50q@8Vy=> z=s)0`9zVvIphklh9jYhD#~2gTXwag=@YU<`c!4n{sL`NBhw4f4F~$Tn8no!pf3!Q@ zKgO7#MuQd|`hQT9=3|TrYP6`HNyo>Sph1U~XBmeHYBXrkVdXjUP@_SM4l93TJSM2o zqC@q3I=+GlYP9H3y})>kF+q(6Ejm<=e2g(cjRq|`R4Ye2g(cjRq|`^#3d-J-;x<1T`A8=uo{vKE{}!MuQd|s#nR!7!%ZJ(4s^28u=Jw zf*K84bf{h@A7e~Vqd|)f)f?nvj0w8c+y9)7OVFUh%A0IQgKo8NrQ6ktFAdM{72>$Y z@ceFw&3oy1Tccc05UW+x^M<_m{WR8SQGJm1$C#i&hm{Z0aS3X)s6L_&YBcDu@-cPL zphbuN9~q|SFUFXlMvLlG#$$pSEjq0Hg?XSxgBI0i>A1W%{JV&lI6;jDEvmmVAB<6> zMTeEo)A2DTXwahif^nFjMvD%^zr>a2NsLjWMTaWGcudfsMU`b7CaBS($}tXOOi-gi ziw;$ue2g(cjRq|`41bnFjvr%8P@_SM4potSj4?rt1}!>NCGs&rjRqa6i18R>f*K84 zbXciN^J7dR1T`A8=unl(!x$4ZXwjjn zCl6!PXwafV)j%F5sL`NBhpLf0j4?rt1}!>NP2^*O8Vy=>sG8Hf7!%ZJ(4xaick(bn zjRq~=>uptIDvnsSFkehiqd|)fU-pK7$ERA!#~2gTXwafV)q{MDF~L2(;dxFIpG<6s zEjp~Uu^yP9L5mJmPxg;7CaBS%MTe>v`50q@8Vy=hy~)EEH5#<&P*upo7}xj8=irGo zT6C!TkdHAYXwafV)t5YsF+q(69lq=h^HTlD#{@MRT<9I@&nrvpQ1vGtV@yz^L5mL6 z0P-=$1T`A8=uizLA7e~Vqd|)fD}&Pf1PwY=gVXIXYP6_^uzyU@pj*9tD0MJFjRq|` zRKv(ajRqa6;f%us4LVdKm@met(V{~&lJOX$MuQgBDCU6)8no!JGMe$I(V{~&hH;pn zMuQgBSjJ<58Vx$EjAJ}%H0aQOJ%e1A#Hi7rMTct5bbJM4Oi-giiw@OVsMaPA zV@yz^MTcsgG%v;kH5#<&P)%ij7-NDO4O(=lrjd^^CTP&2!%ED2F+q(6Ejm=w*&oK3 zphklh9jbN7#~2gTXwacrk35VqL5&72I;^bE@nV7+4O&#+VSgB7f*K7vR2z_oF(#%i7iBc238~n4m$2Y9{-| z7!%ZJ(4s@N3;7shf*K84bf|VEA7e~Vqd|)fRYE={XwafVwHxCx#soDQwCGUnPCmw% zphklh9jZOZ#~2gTXwZK#SNi_e-l=ho;XFu)b&c}={66V;jTY7SsfP&~bg1@af2h%- zL-hmdVT=iCwCJ$1AN4UojTRlM{n-yDsL`NB^+U#Ef*K84bf^v>4`WPFqd|)f)q&(= zj0tKqXwjiMhJaiV#soDQwCGSBN#!z=Hn&7uy*n4m_379FaS$j2BH)M(J6Lp7Uxj4?rt z1}!>NCzFpcCaBS%MThDX@-fB)H5#<&P|YD9V@yz^L5mL6kI2Uu6VzzXqC+*8eAH-8 zOZ#n&@_Te*b$YrzD9_Vpq_IYe>ddr1#sm#Ito%3~m!L+A>MZJ@MuQG3XHy3aT6C!9 zrQ<3XV}cqjs&g2R32LnhEjp~6Pkl_#pha~7 z<1j&u79FY!nFq$G(V|0j5#upIgBI1rjKc&qT2wz_9LAWSMuQd|ss-d@j0tKqXwjj% zgnW!KL5&72I#idEk1-~w(V#_#>N4^%L5&6-D#Lh;F+q(6Ejp}Rp617xphklh)fMay zV@yz^L5mL6mFy1_)M(M6TFCw|#soDQwCGS>MLx!uphklh9aetId@(_T79Faeu|JG4 zL5&72I#gGak1-~w(V#_#>gVKRf*K84bf~T&4`WQwphbu37vy1#8Vy=>sIDas6VzzX zqC<5Zc^G4Y8Vy=>sD4R4CaBS%MThG8G%v;kH5#<&uyO-=n4m_379FY^nI|Tw(V#_# z>L&6q#soDQwCGU%ihPVQL5&72I;`Bx@nV7oEjm=cW`7uCf*K84bf|tqKE{}!MuQgB zE#zU08Vy=>s1}ijF(#TqJd80xgBBgCTgk&16VzzXp;|&7CaBS%MThN_mGDg4LbB+mY&WN6Ex^h{g(M+j2bOERKH_9#;DPtMRgzZzyu9i zbXd8c@u<CZRDn4m_34#OY$lh2i6f*K84bf}h* zhcPB-(4s^2IC&VOMuQd|`adR+&J$xyP@_eM>WMTj#soDQwCGU%f&F2O32HQG(V=>h ze2g(cgBBfDo?^b3phklh9r`~5ldccOn4m_379FZ*$j2BH)M(J5dX_wlF+q(6Ejp|` z$MIr<8Vy=he`J3cV}cqDI#kb-hcPCo(V#_#>IL#K#soDQbm;%&MS8zrj0tMA=+OUh zpEM6+Owgc3hv82_%Fi>#n4m_34%JKKVT=iCG-%PG|Dzu1elf-bH5#<&P`yGv#+aZ% ziw-NVrgTU8d#soDQwCGU1Lq5is zphklh9r`~(lO8X|1G5q*HHP<7HL1F+q(6Ejm<_$j2BH)M(J6Lp7Ovj4?rt1}!>NQ_}oM71hK}zf{j> z-p`EAu9=;jotj-YyL0xK?8Vt@vfJhM%^jROHTUD({M?nf>vF%%y`FnF*D>FeADkbX zpOD`qzfFGk{89On^FPjCl>ce|y8N&5OY-;SAIZOze>?wS{;&Bih5ABEp;DMxIHGWC z;kSjQg{KOi77E3##pYtqV*ldE;>2QH+_<e!&M7V`-dlXI_*n7D;;7P;(!QnR zN+*@(l+G*tq_nJ*jXFp5(THfR=aDhpDr()j{_3@k zgVj47hp3euhpP8Fj!+-?Kgs?@$I&X&X^g7tG*)%$q}1kPruJ0og)Zy*HtMs1Z*LvC zsc#qe3hiSPzZd$ahW5rup}kXXA2o4nfBVvPLc8Il(C$4ov4`<>7Oh8i-(4}E$S88 zBZq``qn^?;G|cLjUZFi=NN6|g8QL?X|8(i!MEZM3|1CqqtbWrgw1*D~?FKzVd%Erf2;KWQu?ox{{5wY9qB(w`e#XhEd6UpfBO|4+4*nRdDrP-=C!LmZQal_Z*pia zn-}tGO>;ogR7yO%Ck_(?Wa6x}mMTkH*TF+Iy-jWA>D+LnP|~>3LM{ z*^=CwyGYgzvadU&XR3@jTe2>dtPiB8_J03U99FXS-hX_0=xLZ7+HuoDd)m68tvxGx z%b418;|uB8Lb7(1tlvo1?Q(9szfP#Isf@Wq&X8YB4L!qT%<+A7CEm`MER_%E>SjN|wg>m}=U=~+j{oF!S8NY?w(Q~T4`sdG3#YkvlZcM3g^4hrpwZJ{00 zF|>UokGvUgF<_JTWCjh3~lXW z#q%oCb0D0ONdot}^}wU1HDWXwg9H9@jIm3?h1Gu%dIc%fu9$P8bRF+Y{8 zoh7TI^gJwCS4q}F$@*Bb_K>XcJ;VIFOV4eRwV!15lsZRC)=`qRRK`q@tVpsRkTGXV z)=0^ESN64;WX+JQvm~pdWIZQiE|ILQBr7XDzm=?SdQ3j6?&}|!Zyg`b-u92r7q1!m z+doPlvsUPDm1oky>xA1U#-V*?Txi=rhEE$G&b``a)%1~k^qQf+{o{GJwL<@86GD5( z+M#Wf=Ym<|!tM5t_Qy&6_K*2HOa1nb{F_Pr=cN9hrT&&u|2nDP{&``s)NlXnaFNt+ z{~U3&)NhgJmqBvnaDvo-TI$b`{zIhy0qL)o`aR^?XRfXYo4dp4E^n&t=^aZGbV(sSF9b{Pvo)jfN|mW z*F2X={r1mj8%v$`&uf#{3U#d1e^TmClV^d8rT*7E>q-6g&wX>Ge*0&@y`}zNrGA4v zU+gFKe<$@P$uq*X(tn-wE2;m5^beXE=61e3Q#k2=L7p!@l>S}h*}=+w6RCfI%>TEt zKI=;T_Rqg-N}aEH7M42gpNoq!|MR5&FQk4)ndgyG|7)J9rGES8>n){z`)BR-r2Yd^ z-%0(Ar2dsszx{Li^-{n6Gy7Rmzy0(3fl|M#tY=SI=fkA_BT|1|>EBiQ@09*JsUOKY zkCt`5OxFJm>3>)HzmWcYWc?qI{q8ICKT_uZu&n>4QosGx#Z;-&{u*PH)MNm(dwbXBa9dfMHZ+|tilhkj2O)^93KP&a$kosFl{cELu`zw}3QosFm%Y{()|#XQ_XZ)PGv)_meuqq|PQ%|3RtWQR;M*tFNI_VV2Z?Q~GC1{fngj zO4+)X)SoN$e<}5wq|SCye>pCt94mj0Pi|9z>Sl{&ji{oAGfXsN%M^e>eD zkEH)Y>F*`=PnP{YDf|6{?Du8q-$v@+BKzG=>hC49a6hqtoD)-d7@VwmqTKDHs=x=|;ySNnk-x(O%OM8U& zXt_Flx)^S^zaE|`_1j++?=SV+UmG7M^{W__ zQh%t_A1(F2kP5S;{&=}E?<-g4N63}=OH${>!Qtrck*o2O<@!7){gdS?{SB#et<1At z>aQ3O#;!#^8h&nFc+P5nC4Y4k`rBX6FDixpw+Dvyk{+QwO0FB9Du&zb zul9GA`t7gz_mleVul$dd`qxVRouvL0sei51Z~wZWx72U{>R_7GZ~q!$s?;AM^+!ql z&!vKv`s3u9xlgZfEQiZA^Pi-S8ytG>ZVT;fxpK}*|0KD7eqHMPLgrbP`fm*gBj0Nc z?J}vqwbXC_3gtxE+WvLQWm3QWtCj1e{wp&7`Lf>wWxtO|{r0bAwvzhoU(xI?_1nL$ z*;ndcDfPFM`a`7tl~TX`tDYvQ-~P4F2&v!x70{YezasT}O8pO{!jV#csH}f?S^xcI z{hyXPPYw<%xmec!U|Ii9q|WF;q5lt3=L(r;o%FvX>;HzV{{vEgQ>own_16)ywf(EG z^JTy7UyEHL^`DdZpDz2IDf|7M)NlVvZ4;^A{`J}pQosGHwnXY*EcG{*`W2~vvD9z> znl3N(+rP5wFZJ8Mz8fy}yGZ>8ssEN#I6&(6k@fE?>%XI{|D#gp;lW{@Z;zO4UB zsncsv=zmD+TqN^U(*KOCzmxU1Qh!~kzd-8$MC!jI^?xW^cN`Mx4C@)%uF`+9?Dtnv ze|On$gY0*{)PG#&f3)m(YuWGZQvV>Sf1uRAN9s(J`Xi3?17 zY$x^SO8r>sH%tFKslUF|Ur*}Kk@|m;I$5dzxYU_1^?OMFCQ|xCv|Qc99HIMQh!&e|C-ckl=}BdowKF>M^fi8S^pf2j1IBmIkqhTKKHLVM(p&~DUIdWMFqTY81|h#{ffuxDt` zkp9!9e-r8NA^q)VII{EK&U4r4p>pkPr>z@$=1mUmWz$0Yq4d-q#RswMz2;b6ogR7y zO%Ck_(?Wa6x}mK-#<4P{_DIVzW>3jFM6w=`o=0V6O0pWeNY)LquREk?s*E{XvM!aZ z52UAd-8yv+Gp}8};hjRyqk}?wVq0j(bPVmHnpN%CIn23sZHILVJ&z0u?Fnt69o;ds zwQK%@jHzAu=VZ*4k~Lkj^3s#YzIK&+>I%u~CHL1mGUf&uv$te5O3zar!oH$Dhx_){ zp~v`kaj(!mHt~eeKQ**BP73Xva{H)>v;FN$*9q;0lR~@q)X*-J{s*Q1Q0YHM`uCbR z*Ka+2ozRY-6xyw(hIXFx-y;2q^q(mG2T1>X>0d|scb5K(rQb^bzS2Ke`WFukb6eCa zv_}pJ?M6MNXK0w!Exkf}#E{T#*fX?eNdM{5zlrqskp5eS{(p4+e|(Si|Ns9VzkeUc zBfpZ?Xo;C6G;57!ek5s0k|frWmLy5iT9PD5lJ-QBmb4^kNs=TXNlQqQBuPt>Bq2%K z=W^U$e|&d(Kl+|JozBnu^LE_t*W=i+$K!U~ACnbd)jN%2M`y%do*Fwv>Q6|$ht$uO z`WsTOl=_`g&y)HiQXen%PExNU^+<{D>UOb>XsB_;Nt-JEzxV&b@c;cT6m^cjuG-&= z{4Q~&tbOd<{MbLGQu}*!ypw$GwZ2n7b&f0P?PD*`k1g*KTl+hBk<6+6eXJ{UZk1K{ z$f_!-d@kR2TfWyf$*MQyTpviKqs)0)R=psrj!UKXtKx@F@qMoS+St}PuGDWIo0T8? zM3>mwubCz?r}itSMk>8!RgtWEM^>$pSI)7#IH5r1l*{X9QOCHFDRaikswuMS2dUJ4 z9Uhb`to>^IR&r8h)upm(x~!`GDs3TiYQI(;nRBD8x?NVSmdeL+g{S2Tuai};$Q8aV zSJ+PGJSMAV$f|=vg(*z;pMWbSXS+moaVBso2+_XR#nKVDp}Q5=G-Hz%4Ag? znKM{cjg(cNNM(zxvSd!7tXeFqR!JpK=1h}S<+AFSRBFFBgD`%5)_#Xx5X6{*!s>ae24$GW3WzKD~s)1C#_TqEZ?iF4bzlv&ijFv%M*_<9b_MF((er)Yd@}117 z-A{JOoR?%(Ct0OSrAYe8O^xG(d9o^1y2@`dXR*vFmR0qovcrpK*6vQKTw(1Vl_ojs zW!1g1s=4IU?p@nuPVEl%h0K{Pt8!%3-*T?&dO`GlR2-*s)4e~m&#^Y zHD6Y}EUW&KRfAWG}H zr>we4R!x&tzO4FI=9J5-KC()a%6eH9M{j5XLO6acs#CC>^E7l$^AdSUHrPO{k;Ca zr$C$RxSsqhIOF2DJ~t=!gG*x@$a7?TRy{lArw4=qdT%O8zgBf34(K zN`CS)WrgG?KUro=e)99BRPr0ivngHnBo9gcPRYMY>i0;!O6v6_KSiEg?d8d}P@ZK+ zrG854t~|d+%JXZRoOhI5|9H9nujOc8$xnW|UC=ImRsY8`E<3I#Kk1H1{mGp8=u4Nz zo|L|NS5`d!kLRG|CqE5)NKW!IvHity&Kk+zA^G|81bI&K|Ko`%`N_}C$0R@b>3N&v zpO*ak@~jyq`5#Mut~^bymwKht70IuWdV0tB+Mbc8%=c2?BhQ*YrGAqV#!b56-<--2lKN_?yOQt7eQqQ7 zd9K|515!UC^%|)Uk^5gI=N&57Un1APS?+&<EZ4t7 zj$SAE110|v$=@mYO(iE&a;}v8PbJ@%9A9>BEhS;RO`y~HQsdti`DCq_&b^X`8%avDEY@EUz40$B!89Uw~_pwQh!%TXTotqN-fEW9X z6`Q<6p6tfY<>YSPu@}ADf!9!>IWsi zQt}5%{-u&%Df!8}?sFwSdGFmx@{@Pq`I6sK^4my$jUgPDA3ZyEo85^7qO0&ye$ukn?Vl z{N&F;eI!5m)6gxFpZu9B!}`+q?0f0g7H zNPhBXxBKL1@+Y`w1>SZ7FoKdBdA z@&-qzQkhCrPNcTzPx38Rqr&89i0(-d1~wwsXrn09#TJB z>TgKBQtEd~Jx}V7NPWE2J4wBc)RXVN{Os0 zmsQ8)3NM#c#jRmrNhGUpyyRVJ(I$eh8lYNV|CL@HZkl_hfu zWz}L?wMr^^GH05sDwkEqq*D9c7liTav-W%Nf*`JZo*p~yoY=O0?7Owzn|>I-GHbs> zGlRIYB|Y|`b7I^0v9;g1dt^@S_wTndr$SbBmQ{vSZkBTu$@~2!S=CtH^@nB7n=p)b1F&WX?;ns*|kJrBWmv zTfyM^>T%Mi0;!O6v6_KSiEC z?d9pSP@YFerG854t~`TA$}?!2oOhI5|9H9nujOc8$xnVlUC=ImW&X!=Dm$(xKdp{Q z{mGp8=u4Nzo|N8rS5`d!k7t?WCqL18NKW!|t^LJu&Kk+zA^G|86nIYZ|Kq7A`N_|| z$0R@b33!|2pO*ak^1K)(`5#Mut~?R0mwKht70IuWdV0tB+Mbao#rIO*BhQOJrGAq< z9oEQsZ2lKN_? zyOQt7eQqQ7d9K|515!UC^%|)Uk^5gI=N&57Un1APS?+&<EZ4t7j$SAE110|v$=@mYO(iE&a;}v8PbJ@%9A9={EhS;RO`y~HQsdti`DCq_&b^X`8%avDEY@EUz40$B!89Uw~_pwQh!%TXT zotqN-fEW9X6`Q=vo$SWX<>bBYLMN^#?|4_(as6=f*cVb_OJ#Su(~8H*`{6>#Pu>;Z zDf!8J;|C?bQt}5%{-u&%Df!7e=W``LdH>u=@{@Pb`I6sK^4my$jUgPDA3ZyEo8csWr{rtOjT>q_kYkA&eud;Gf9g3?@{>RNw37VfPe9p{ zf3D=GO8#+4D3Sb@a{teg`+uk0|DBSvqeXltSIGSzDfj=Rt9LEOLCpA)T`zG zACUWBCHV!CpZxjjJ~^8FDeM_JZ}Mlca>@Tzu78T0w@}XevE(OzQoB;}lRvNZm;B^U zZ8uB)bCTaf^3Rq0=OjP*Go2y%$)D`bm;B_D9v|5r)4OY+m?{wL)A-zfM0 zbIIAOh@Z<3tZa-E9Qzmfa@z1;sblHW!0pO^eOl7Cq8hs)6$ zGvb`g)Yyd7AC>bimHb=ey!GY0GbDeTTz{#Y_c}T6D#^cF@<&MiI?1_2@-LM98p&BB zIoC-3B*||k^`9lDpX5I-`JE)cq14MH|1!z%D*2B|{wc}PB!8Ra%#i#Psb4AiCne{7 z$!RM2rsVrlua+w;mi)6M|FqnP-jcsTa^7zd-L-N_+A&r2|Qt zaw!>_lE-R|->H<1`K-{CPR!Mm&dk!3E*#gCt{l;n%cyBeH~ycdT+aV$N_YNIl`BwF zdhm~`T#1@ez(1ODmC_S?D!uS(r8o9c`rtK6U+k^)!)ulP*hd+F*C~bAS1H2ll|k4~ z8H_h5#n@jNf;TEdaey)m2P(s{P#J+YDI>8+8HG11qj8W@g10E8I9M5jw<%+}!Xc5Vt4^ z{9LJnTa|kFh0*}GDGl*Ur4epdQt&G!6?Z62@M|RvcPdTs8>JcUQqu8T{=>!^UuPzM zr?kR7N^AUHX^VT6Ec`*q#%d)8e^hdDpV9$;Qu1)Wl8-+to$-Lu6@O8>;X$Q4{;KrA zLrMYuru4+aN-z9f>5WH}KKO^y7mq6a@R-sckMp0))097zLOh`q;a|!iJgE%Ezm;M< zr3}G;_z#_F+{a<~uQD8Klo6@4$iCT{5s&hG-hWuVUbsjcVEAV`EJ~mSqaI`sUd>0pD z3w05d4AhjCY9(f>i}3<=3AR#~;)UumY^^TGHtGs&tFFY0)K!?JuEuuiTFh40;l=8D z%uzSsC2AGssvEJrx(Pd|TkukKE9R-&u%o&i^VJ>LN!^K^)m_*{-HlzLDyp593wp5$vfR#jDlh*h@Wu*Qh74w|WY%RZn9dwFa+K zRgIpk>Uh0sVn5Z!8&ntjt3KYShB!b?;6Sww7OM5|Cba<;sSWXFwGj?dQ}7lw6$h(L z@K!Yqi`Axho7xPAsOfmSnt?;rOuR#Fg~Qa=c&FMHhpSn5mzs?u)EvB9&Bc*w2fRnk z!%=EJ-m7-T(P~$`Pwj>!YInR}?SZ9g0Y0Gi#4&0wd{FI;W7R(RklGi=sr~Q~wZEn+ z6Oi7k4q!YR>Ah+p<2gw0Rf`yxBfVE0#CR@h%8Tk?oTnC3c?s#g>JY{+qo&MPhcbQz z>AmVO#;+p1R~?S8sUxT?LVB+{l5r){d(}~l7bCq_9nE+N(tFhsT&k8*c^m1y>KMlF zAiY-|%lKWS_p0L_MEpRVgsaubxJI3VYt^avp*oFK>yX~7mN8zB z^j>v3Zct}Z`2^{`>MZRiU3BfVFhhhL}_xJ{jpU#bf@ zx*h4g>O#glklw2Q4Mi-Nl@fNbgm5Gd_j%UUd(iR`=q+YBkoV`#7p-`xvX*e#RPV zimn|%Lpw;tM0&4w2yN{!6$k0P+7a}$qf~s|&kSXZmT zdKzzdrM{+P1I@%UH5(ghE}o_N*hmZUY%PH)S{*z`tB0vt18l4{#3ouJJXcG>G%Xd+ z)0$vYEe+4tnqo7p88+9_F^KvDs7P7 zt98JOv^>nx@~O8&dau?QFV?zZj@At?(Yj-<)&tvX1=vCBiI-}#D1!a{8_ z-lR>zB5f+(tWCo~S{dG=O~=97OuSW_g~i%z&UhQrd$l=uyH<`vwYk*qKzgq>5AW0} zaJV)f@6s0F2yG$Wtu4ZlS|#42EyhvW61-Piilen-dvEOkPS+0N4DArk)DGh_+7X~&}xwGX3+mN9p`B#zNFb$p}F|7=Hq-V#82 z^naxPYxVFotpP658sh6(BdpX?@C_}MnTwJBuQkCXS{lBkHN~Y`GkjZ1$7NauGv7h_ zzm|#bYOQdE)*9c_+Tuzr3*XnWag~;XA85I_TI;~tHAw&0^6*0~AJ=J}@guD(uGhNZ z$69yXp!L8{v;wTsdg7;AFWjj0#?Q1qxJm1ao3(zpMeC2BYXfkrR)}9{MYv5HgkNfd zal2N`HGGBay|p3uwKf!YYQw01gY(pP_iGdJXKfN5&?e(A+7vvfO~qfeX`Jy8(*LzGJgiN}-?f?4k0AYD zn}tWU*?3HwgU7XU{8O8YC$xF^msWu%wfXqBwt%&#kp8bN#M9a${8y{Q8f`Hu`Vv(2 zrOecj{;w}XLtl=jz5*?MCEEHbboABe>TA){*P*YkXKjG&z4Z+k=~bA}H&QHqpB ztgCOqdiqwZuW!Q!`gS~1-+>MFop_eM3mfUX@oaq$rs#X|9K9M-^?le_-;Yi719+}} z5YzNSc%FV3o9ajKeEldk(~o0w{RF1#C$WWo3N!T6*ix^-Or5Q#a)GX6E8R3zo(&yNdMPe#up*|U-uceL;AlS;>CJ`N)FQh^*W4mk^ZmOW84Ah|9S()dC1;dZ;1JN zBPyMc{;#Jn?t=7xJ(clg$lhCT!uWEe|LbXZh29i<=*{p-Jsk`547^Iu#GZO9yjpL~ zs$NL{*V{7gjr4y#3;XEVRIWq%zn+8F>$z0=A^l(Pfc^D6DmNniU(aVe5b6JVXS_-8 zibZ-iyjkzg(LqT6*LyG?jP!rK0E_jWc$?k}hv>cWcD)aCh9Y}!y)O>a`{A8>f9k`L z{;v;UJOb(edLfR~i>Ta#^nZO2-m4GB(RwlU`;h*x55fEOp;Std{;v;XJO=6i`fwbp zkHClYk<`bbraY{V!twfOd_*t733@3$s*hpLM5O=gV;N6E`oBI7C+p+!34H=i(I;~B zNu>YllNdjR^nZOaKCMroQik+@eJW1Zr%{=K^nbk!pV6mNnT72C^_h%kBmG~Wg>&@T z_`E&`%k^@6L7$6r^?CTBUV-!U`S_B)04wx`__DqT=j)aDioO^Z=u7ZbeJL)~m*H#r za$KaZz}NMaSgEhVH}uuGSYL~8>g#Zcz8>GwH{epe3g6Z@;xc^`zN2r!<@#29SKo#! z^zHbbz5`e4JNb&oEVxIsUJ zpXi6NN$qJv@hjcN z9lDEO>pt$(L;OZhSgNuM>Hm5i+^yHc@AL-L_aOaWZ-{&KM)-rCg4KE|{-`&>eR>-H zq&LO=dNcf4Psam#2L7UF;z7L?{;IdeLwZ~MP0zx^dN%&9=im`Nmoxr>^nbkr9@F#i zxSmh_Po)3ro$)WdE1uN5;oo|9Jf-)*fAj)8t@p%#^2BT{fqh}03-x$g@1W5lkhGAq3$AmEg&oD+}9b**MHAZ7SqXg?4 zrP#n2!`d^E{%?%Mvy5@r$QX}j8xt_an26^XlbD%`^nYVAHZi8)xyDpXGp6BrMj19W zrZe+=r2iW;vAHn|(~a5K!kB{@Mme@L=3=HX4=*q(u$3_%FEkclYhxj{F&1H4qY^JN z7GsvNg#30$|2LN6#l|wsF_z;c#tO_eR$_Z&6*D^^{oh!PdB$4oXspA0V?A~r=%ZyFf&DesM8(Xovu??>S@(8AtF2<0$qwj^mBS2^?UY#DT^sEHqBzO-2nC z8T_zDx!KThkYU=YatpHmH*CDsaIx6%so#e5efjwlJsf5Q8>>SjV~D`SYedn%f=X-Z;ZuPjB&WY7>})8Os=-M7o@@98Vc5@E>C(o;FtDzs72;G1j7Du0z#akD9pwb+ZZ$b0aw>(&fxe zjBTXLnOo2`x1wimL*Lwvfw==ib0h=`TfEuKV*VgxhiPUr9*lGtGly|8vcoiUafsQ0%I!#pG4mMT zfpi!%pYfeYhcP?jU1nDtVRpm2&F(nT?1A@~1vtv=$^3hf4rBJh`^?@{N{|j?_Q6uK zFFs)Q!!c%mjy{O&FwFt@kXeZ1%p!c)9K_M_NQW^8;{>x9A2o;IL~|%dA47h^U=G8_ z&EZrgBOS&Zfm6(p_@p@sr<$YjDYFF2%u;6l5BWK&Ifn6CWFKXYWxNjAN15Yry*ZxB z$H+d)oWS@K?ow*qIm`m_`b1CjMm*EfQa;!F2 zaGgIQ`zUiISgfQQT~{LS2mhs{m+ySW9Am|O7= za~mEtx8pH$2Oc+f;-BU&JYnv}zsx;&(%g%Go7H&A+=u^|`|-4S0RJ@)VvTtSb?Y!! zXdt^L>j+~D*)>^589PWnv5qtLkbYvFzG<^71fA1#yd)yVtNvKe24Jn=0TueE$CeUSH~6*BINJn^jr<9^8d(W-;} zt$KK))c^-r4RN5=2n($gyva&sei8D1w3^@`D~-x6$P?dcinm(Lu-Hn++pG+Z4ndyy zRwfR$THzg5YaC{^<>;Nr`_an6yR2*+Vddc6RxU?JBJW451CFxtsN9RZAFX`6&+3dN zR#&{=>V~COcYMI=fn%%!e9-ENW368JkkuQk8| z;!JBCK4Xo?S=I!6)|!a3tx5QtH5unvQ}B6fDwbQ*@CB<3=UUV8MQbL`vu5E-)@-b> z=HSa#InKA{;w#oXTwqn;tJZv6Xf43ktcAG9T7<7#l~`#l#y6}bxY$~XZ(7T6iM1Tx zvR2?yYbCyIt-@v2YJA69i_5Kb_^!1cS6CbHJ*x^=S{w0wYZI=rw%`ZWR$Ogu!!_1+ zTx;#X53QZJ&f0|^S-Ww)wFf`8_TmPs8b7i2VU@KXKeZ0vM(ZGcW*y>Hu?gu^)?vn5 zkWOVCVZ0UTRMt`4W*w*UCDN&^6O6w?I+b;j@z+SFvQFVQ)@j^j)!?_5>d~nz9lx_o z++*3Es(g=hD$B(mET2j>@`=w1ai5jIpR78#->S#apOH>wHNao2hIr6wguhxT96f|| zDk~KaTTSqHD-DlWO*#4p(y6Rwc+5(tavbSYRtBE1GVw2~6`r(Ot1+Ma`L>~d^t&&7-Ed6;EaU^{z0X4?z!VtXOx*o*KIyApHl#n|3n zf*tIoc&WV%^X%o=(O!Z1_DbwzufopuYV2aK#jf@`yv$yY-Ruo`xm|_b?TvVay$O5R zTkuMID;C(>u$R4^*TXePx3_mNz82Yi**h6uhwQ%WU5u|sc3<{x#y23{-rmFbMx@)@ zdl?Tzy1iY^_$FlcW$$BrGt%wt{W#n{K;iXEb~7qpAf3lf$1m*+D%+7glAVb=>{e91 zMmmq(n(;SC=ds)3w{{lpwzKg&JBOotkUf%}%Xlx+dF&2YZRg>Sc0TU2JL6AwSLW;a5VBAv%B#8Y+={$mfKej4dK_F$~Bi&1fgpy~`o%^AiV9qByIaKoCVzkC!+bFxRQ#D{7B)9%mz7>TJS1XAAX?Nau03Vkc)Cc6PR77iR}{b#~%q z&Mxfc?8eKTJ=opZ%i1fD&f`?$mCimaaQ5R>&H?P{9K@@gL(J@jd=hjHV{hjOUh5pi zKF)Ex&N+d7os)RIa|-)8r||}-hPC~Xou{J)^dd*cfsPrdN+GiIbZjhgT)f%wagY<@ zElvUlJ9Y3@rydqN4e&OnAr5gG;q6We4s}xT4yOqYbJFlmrzsA1n&DkeI*xEMIOE;O zCqX9@?{Qk;D5o{`dy$=|(-!Y@varO-#`~QdEOm160jC3waq{p%Cm+W;o$(>3D~@xz z;loaM9PjkNN1Otj;Pk{tonAQ6>5Y#$eQ=V~7aw=};bf;j*YE_g^K=H_lTIN{b&BvQ zXAn+v2IJFCF_t+)@PE!wobC)`?F?k+=?uqboDn$78Hvw2qj0t}8lQ7Ym^la8c{-(7 z?u@|~oUu698HX=A<8hudftfEMJ5OgKzU)lG`Oai~#hHQ&oT>P#GYuCyW%!yi9Tzz> z@pWewRywos4QCE6cFOThXD%*r=8^vv(*KFKO8n4Sh3lNv_>r>~*E{R*V`n{Xa5ms4P8C); z8(I4)(*K=J_?fc>H#u8zv$GAiINR}aX9sR|cH$S#F5Kqq#xI>cxZT-{Updvd!`X*l zJNt2`a{#|_4&pB75Ps_%#@)^l{LVRwdz|B3!}rL}(>Z}ZI47~%IYs?Pr2jjo@h7JS z_dEP+bIQ+-jt3kQe{pO)=(zZ+ZH=ni7Bx2ubvGLgH;1(* z(*NCDwA~KqxOwQh`RKWw(RaHtGeG*k+YKYPJ0{#7c!pbmb=;m<*X@P%+}>E6 zzN|eH*?GGC@GQ4KHgX40KO5=)ZXurI7GbJ82phYDv58xZ=ek2M%^iy8xx=ujI~>n< zM_@B|BsOrTdt+$osl zPQ`ZaG|YC(@M3p5=D0Jt=1Y+N@6KY}9_jz?Y{r)&{okF#xFgd4-E!>Y&ZW{B>HqFL z#$A#A?^ZDGhV*}TKI87l&eL6hJ=}#$8DEX;Mcu`WuR;31y9BRw zmtr4x8D8fu$G+|gyxv`j{oGY}gS(nl{gM9fu4Oy`>HqFJEOggXxe4k2?gqTst)emr z>HqFV9PDnQax2pR-7So7L;AnF6>oR9;ZS!w-r?@x=rE-JyE_>VNBY0J3rD!S@osky zj&%3pJ#IB~Mj?OJ;O@iG?tZ+_JwUw#>HqFQ#-&LAcMsth_b`~dAo7z3SH~%?iBGyVPIX;;%JrEu z4e9@G$hZvY|84@OyLE7eTMuWt4LJG?(*NCtjGsmNzuO3(b5p3yLHfU&isfz-DlZ`Y z-%Z08-KJFLA^U%~8RH71|GVip-_5{R+)P~Hw!&B4*0|7Zi?6v^xX8`M*WDbfbaU|y zw*xMA^YBeKAD6hD@h!J2E_J)%+irJU=JvpM+yY$g_QZGHUbw>TjqkaAaHZQ9-*@}r zDz`s=;10mmZXvF5i*T(w2tRZO<2tt(KXQlQdUq&a(Z@*tcZcC8?r^MfM^OJ1>HqFX z{LCGNo7~a3*)72>ZYh55j=`<&Sp32rhuhroto;({|Lz3*%AJTi+)4PgI~jMnQ}7#i zDl>N>{okF2yWKMU&Yg~X+?n{jI}7)^v+)Oa4pzJ6_@g_Qwfm6%@6N;hZUz4A&Zm9= z>HqEmJm@aOU)@D`$gRZR+{Jj8{2T?pplI zU56*#_4v2D0Z+MAobex||GOLUUw0GMxLc?z-WJBHw-q&S8|vP6G`t;XdOOkbcA@R< zM#tNOuD2IGuNr-C9|qoj47~#wc?U7!9l|rb!&t{Vf_1&4SkF6-^}Q3=z&pt`oQd>* z?-ZWpoyJC94W8}sub3+-o{r~uCZ>8eHuhX>;`s?xITz{wUWn&;32f@s!SlU(*vxBy z&Ao=qOh@{^*9bGb6m03GVy4#wFYwZ^mDiM+7b5-NYldySbZqNo;6+|0W_hi!o!1(( zy|#FHl6=?BsRB&R%!y;`P9;UIAX_ z^<-u@r2l)pu)EhAukiX{53es?>Gi_`uRk-dLi)cq0I&86v6okb*LZ`lw>KEC^@_2N zHw3TqhGJiD7;CRb`oA|EZ}3K7e{Uq-=#9bw-e?@?m0+P)iZ^*Zyi47t*1T->Hppaoa|NM6W&Ig;%&kwy)8J^+lo(l z+i;q<9iR4gu(k~8|K3iV?(M=E-fo=f?ZIcfy*SIOX6Ca<|M&LcbKZWO;~l`~y@Ocp z9l{s9!#LMFf-ibUah`XawJ#z2fA0jo?488<-YM#@ApPGvjjwt&xX@G2p#OV1F7ix# z-LtXMbMXz&$HiWVZ+Zz_;?=>oyn49QYk+Ti4RM**2;cEiaJiR??|Myeg_nl!c};Pp z*9_nH(s7lSfggC8xY}!lYrNLD)@zF&d0A)h_Y067>t!?EgY;N0hw)yd$9lO~?RB8? zBeKW#@)-Yw^jI$+fA%`#0k13m;&sD=UU&S}>w$;70{qSEiHE&j_`BB|k9d9X53esC z_4?s4uRk952H>AwA)fGx@Gox=p7aLe-(E4E@`m6)-cUU44a0xE;aKC1K*b-4sy~YB z)Q}$Qk7jHjJ=QNl%P&RSAA^oR7F~ZFdj5Fy{RtTO6EXBBVdPK7gg*t(@TX!Oe;U^H z%dnn59qaouv4KAe&-7uT_7+;3`9K&DE_;RF=`77`We`jNFVdJ z;f?-w9N_Q3f&Na879xGj--Si~ZoJvwgM<9N9K8kUV}3Q>>hHs1e?Q*lAK>T^q>uRr zaj1WY${k1_^AF>l{t+DRAH}=;<2b@Ufp_~Saio6=@9|ILD8B~p_4x}_%4lE5`+O5i zd>ilgT`cu|e83NJjGw><{W>_-uZIu$4RD;_5Fhp%;dnmo-xMG7 zo8crs9Uu2IaI&9?Px!5Hir*Ta^xNW8KMSAovvHcAgHQXpSmt-Y|M_`1-OtAverKHN zcg1J?ZaB;Dj?el%aJFB7&-p!Zj^7KP_j_Zx-v?jt`{G=`AHL}K$9et$e914w3cmE-}lRKl|LOn@Mq#`e-^IsXX9Fb z4u0sD*Wu6lk)GzyWxO8wIfg%v@djic>sMfvKcC8{$UfFz!1yy{AL}ngfAY8Det#QBe@1$&za4+^cThQq^jLo<9`bkL zZ~ksP?C-(f{k?d^uf{+8eR$O0kH`E2c-%jTfBJ{;gntj0UHN-*d)lubA!&97Iek)f^OI}=#J+HJ+N6&fX#!Rm>%@P7C~>!2>M{lpf6?y z{qTaIKeh@6;DtdUwhoH0O)v=C27~dUpcu1)A=oY$irK+1yf_$+Il&0LBp8Xg!6`kEGVOLC(=uU>5T6}dTB6|@!d!-4Q4UE2kE83Y`iy^LuEA5OM`O8 zB}gv~<}xltdTB6^@ff6+1{F9qm`~*)q?ZN@7(a~k(qJLuN043`EMoj9(o2I%d@NXu zlY%Aqc(4>F2g~q@U^z|+R^XGtN>)updTFqV@ie5D2CK0wSWD%9NG}c6;f!ECm6=E{ z4L0Dcpo+?~NG}aGGJX!}rNJhAKG=fg!B%`B*v8ShNG}bxGoFX^(qIQx1UvENU>D90 zcH=9-9_B1SdTFp17Y5b%TCk7$BBYlF`x#dvy)-z0i-Uty-b8w7a0uTD4&&0`2=%v- zUK$+5cY@p|TzMnLv=ncn8u;gQmDMXolYe>9{M%z;A<0+#R&S?}FC2Cuob`2U)l`$i^Rn z9IOs<@yDP8?hEqpryw8q2c7Zfper5-y5TQDcRU#Mz+Zy`JQVcA--2FvIOvVP2Yv8J z&=>y*`r*-_KOPGP;PIdk{|t)oL@)^d3I^lJpqQ`dZ={z7L-3zqD4q_6QU4d|rNMAi z!V##3BT)-Sp&pJ#BP>BPEJZ6EgLXKUwGPrt!*S?^xE@lKb(#Y!kKtxI13wwv+=BO4r?1By)-PxlyEMd6V9WaiuBU30-J>M z@!W6$riBafyl@dV4J+~da4|Lumtga7DW-?Zutm5WGr|?vGF*w7;VQf!T#c>5wRmB; z4qJ!oIb$27mxdegqOc0H!j071A-y!*gcpZfFeluKmxS9eH{6cx!yVWm+=-WlyD%@@ zjUB^1m>=%NPGL264)^Y14hKNARDygu}?Ul`&IVFLSyb@0Zp z9y13Zd+D$N7KRP+rmztfg(-M*n2LkKCd|AA*-M9Mcx%`ci^FDkTbPbR!VJ7U%*3H# zE4(9Yjl;sWcxRY}!^3R6E6l+WVJ_YscEFKg9{Kkm{Xfjdd&AB+I_!$~h25|u?2h+` zJ(yXF^#8B`$Amrc!LS#O4SVB5VILe9_GRY7NdFJ};Ui&xoDdGcN5ev#7#87U;UJt8 z4#vmBVw@ZfVeJ!0{||@ali@I&8V<*&!Vx$v9Enedqp&O-jsFWvaC%sZGr}=AGaQT0 zgyV2lI3Ax3C*bUGB0d*R!a3n&d_J6l<>6F(A)JPD!!oYnMPx4>PREzRnOG6dqW&_{ z|HIk%N;n4>gyr~ZI2RX&^YFE>0vCnz@%3;4R)!1ljc^ez4lD7^a4{|km*89BQd}A? z!?(lbxGY?O?}RHk<8q|`hpTW!xEkLJ*HT}J^#5=jt_s)V2jK=>9aiC*a3ih_H{plj z7F-u@#gD>mtX+@v|8P5Q2zTHo;ZCdycj2euZrm8|VdiH@{}1=#=CB&Kg!}OGa6fJh z58xN!LEIJ|!Y{+axIH|=+OLqkba)iM4v*u`@C5a5kp3T@#Bak>xH~+J--R`}C*)UP zDBp)V?hQ@+A+)hNbn(Z~$9-XlKZOb0AJ)O2!+LliY=FOn4e?;u2!9Pz@KBhFzlBZk zaF~X_hfVQF*bM&&)A4ASfycs3JRY{fKf~5|B5d10RsKTyf0)JiZ>0Z+*^K`|`hS?i z_+O;|hqq!5P@)?^*|BpH|wvoMb)D_*R8x;@f|510w0n-1Y9*iSo zFC7&yJ_G6hQBSNJ^}>2lZ>%5n!3I%ZJTvNt4Ws^eRy2TBjgbBy6*5ji`hQe}snH-R zjgkHz4aRe$Vk&7!|Br@X(`YD_^O61^4P)FK>HpDiY!QvXjA$gbj7D)Z6Y2laXvVFO z{vVZK>!=jlL}RdRG!`$4#xW-g*-J;`F*}-o7e^DR=OFz*n#4F4>HpDW><~?%aw*dP zqp8?2nuhsN8TC#`|Bt3)muM!Hu1NonW-;!D^#5o!c8})Z6;V0$9>~v9qPbWQ&BLpr z3hWup$E%|S%;|;n|7aoO-bnwC7Ga;L60eIEW8Y{AN3Tcvf3%eG4M_iwmf?-jaw-Fm z{vWNt!e}Lxn~?q=t-_n5)l>!{`~PSytDZHsFw`3U7}#;?QUl-VtrV zVbNB+Gunp3qwRQCv;#*(JMr#l7mkc}<2}(H92M=wd!uR`9qq&WqWxGB9l-megIF3J z!Uv+mI3_xR4@O6EY;+tSica9T=p;TIox<_aX?!HA!3mLiCjCFsabjei$=?n@`hR5O zW}Y418{j%i0?*4xFQ;a??r=gWmL==-$(j?Gz340hT`gI81*$s|Br^_htUXJ7mdV^ zqEWa$8jT-ECAc9f#ZRI!SQU-MPor_TF&dAbMH6sSG!ZvPlW zZBZG18BNFS(M+!4E2RHNv+(O^Htvk(;5Sh@?uzE(x6wS@9aZ3W(R|z!Enw~UNdJ!( z;t$axtd1)2$7nI`i0FZ%F@-*5dEc zIy@4s$3LPCcr>cQW6?%D9&N%uqb+zM+KPWg+wf$x9siDY;HhXQ{uAxO)6s77|3&(L zvMUQ`p+s3rEHp4g8@;s7&Er2i)lqMbN|PU0}Si6iJGj-sD9&ddPm|A`YAB~D@@ zaSG2!oW?qd8myb(*F`Dy5<1pTnAjj;H{{O*k^Y}>@vMZ8jS?ZAok(Cxq7I&usE4VE z2G}^!5St_#;kk(vOiQHVd5I?2G?9kqCz@ijL^EujNXPU<2DV6KVn(7BwoJ6f%tTwR z;R0m;pUA=s6WQ20kwd)=(*F~=cu}GQW+n2lT_PW|6P@wmL|4p7bi+#$-7z=O1KTGG zutTCJUYh8Id5PZGG0_L}6MeB$q91ln^v5oV0i3Zb(*F~M*ey|nmnQ~M?~e5U#9-`^ zD8?%jL$Dw*6t7AQ!=8!Zcy(d~_DYPzYZ9Ya+Z*ZsiP6|6QG(YcO0jQZ3|^lYi~SPg zn0W)z{}bc!#>501keG-A6O*toF&S@4Ou?eWRJ=Jc4F@I4SbGby|4&TETN5*}`2TC~ z?1SV^&idXn`!X}DebG;*fV6Anxu7=jJB0_0q_3n*|=PVB@aM<^3hR6+tFgaRrBRFVQJMY$-7 z#LnmYbob2cu56#-KZ=#6``b@H-Tm~_-A}*#dVVwYLFj9^*#D^?!QGhpQQYd(hjG7S z>c??!PJINoHucYOH>Z9Iw?6eRa2rz}#cfV~4ENcoPvEwueg?Na^>etLsh`K)n))Q} zt*Kwc-JbdsZg=Wm;qFX*2KV;VzrlUa)Gy)QnfiCQ-#PWmxPNczS8)IS)aP(_r~U)( zb5p;DyO{hs?zblY5%-WiTZM=hXmG@_Ex(@Hj{0F?z z@}Kb5$rpJq-yiXAx%r{Thu%F@9_kK#Zs;?kFXTRz`)ux4a!dKA@|FC1@-O87S^g9G z-^u@J{tNm4n;$BCcj1Q$KUMgp!mk#7qwwDfe^MA8d*#^uW6zC!c^AQL`Yzr}^4`MhhrWw9jl8#TVyMA)^xJ$FzQH%l8+;eM z&9}4Ld?R|BZzMPPHgTJ81#k0>-s_q755FsOVrZ4mdWIV;nZGE!EAz#|3*Z-^y^vWQ z`#$iy?6W%dQD~pw^YfW+K4b!|r=8W+&DmMkj^J%4%H>X@_4d;0>YG>RW}W8%cn-iG zj`_#|@Em}*y}i7;dZyZL)`~j|wPL$H_wef7sE>#`Se}?3T^058R<%}<5a8vlQoB>_ zY;{skXwJIMtXpk0wr5?JAE=Ka%+AGX05%(q&PKbFHlgk*V{R_iDY8S^!!aK@0HIF% zI0#Bkyq=tMZt-k=Ycp*XOSSZdLeFj$J0Wl-EmkhqYdg!$VqI}DywIrC(sHNT5LF3k z)y~dBW3yRor7g?EXGwz)x(cg-^TqbYa@rZp=|Z)w@Y5Wy^4t(^N|BU9U85Z^KP-;!QIR6&TZApH|E_t)19lu+E%($ zthNaD>VKwcl`pn-pjH}NsBYdWFs};CyHZ2$sZOyz?@%Ugd8b@U+pEP^*@Dgapp|)d zDeZR7)f&alyj$~LwGLJ{iIna>`Q}dUlXqYEWRU5k33AdkPlnP?_DehIOFQW(E#mIJ zeC8hBSI)lh!z0v!E zbC0@qy-{tatDB9={97K?kl34m;KfE|tCqgqEnHu?aCUk1$`hwoS1zw!Up?(kRVr7C zm1?neu2`!z*49py%V~|#qrotLM!hLrCzLLyo7Kh5X02MTcFvMli^%0mmlv1MuCAP4 zywVF?IeY5N>eW*ho;>R=7CRf~4ix!>JAdlJxz(k`nEd?N<>kdwmm=f23oEN>a}1hcCWcxX{|x!cCNXnuU&W7p1vOPxUM{|@5$qOFOTc4m9`tT zE#-GTp|T4)eDPD%M0fhSDua)=o7AAd)i&Q{&XnvX?IJ*bamA%LD39F z(c|d#d=E;+q6g~{v?3tNCyk&K!FmL(2uSUDBPd0%9zm-IA}=LbqEZBO7*Azv;egvfm)+4}thfE_cQu|UOSdX9;0rfFe$+vPMC`GUyK}$fBiUBdB15b#FMNmX3 zg7wH^z|x3PD{hu5p}VuFtrlDa&eF4h06e;Tww0a<<4aK#BUksoW!FmL(2aa(@6yD zk;Q3D zz#9SMtrS;F8|_=GorX+~rIPQPFys;m#mK=34B17%2n>ls+L$!dxD~=^&&psVdca8Z zECy^;#V8aUY8(?q3M0@*wIaaug)S7*L_$$O*_I+;1ct;t5C}yY(DO#X2n@Ue#SWjf z)%I4&Rd1OWmYbW^t_9iHxV2WX9HvRX{3%t-TYTK|4%>~*6a6)P`@|DzJ#7&cvgx!~ z4!dS~{bWV%!xa`9=h+&VOiRTbmaRktzg4ZQrxlHvN?d3eUMp<|BFh7hSW8LnA|7(f z8<1PU@lFmKv=J9+lci%Qt&u7IL-r0F`xY+Nu-1S!_1(qHx9LYO7dZPgk`~Q^dt; zy}G%z$Lu2)x6)I$iq)F7 zfd?6`XlZ<);7i5LyHKC1w$i)DtRKwciB_?>aga=ZIY_+RJVSO6S>6Pv8h_#CE;RS>FypEGQE4GZ9MOip%oSe!|7`0E*w|V zHUnj$u~qLJtlx62@s*;MJH^&rQn5fknAKvvlHP?ZC4HHwQ!Vxj2Qpf-&TJ2#n1j(oVvxnRdOBKCb!Hk4|<(V zNVT%k&~^|r;Lv5Qb|q!dGoJe7(01><**O?@^KQhY>aE6Ic*(azxvQ06c34<@g6vqI z`b64Us@Cfo%2(1gtdT@(H?~^xs+BbAq*A@55bQdMSwMSkLFQ3<>?zdM6_NpSIuoLX z>*OL+i{5}|vHdQU6OARURcVNTp*O1;W6JCUf+2M)#haw9S5RRCbUIzH)&thORxZ|3 zo7oJks4CXh`z)1BwEJoO!!5U{qrFDklAp0d8-MttQMA%n#)gt_bAuM;bW5#AIet>H z(q#5rOOE=PhO~ib;oNpGb#AGO))d{d3#K9?nM#)$Ei(z4nA#ro%!E6=V~llWP)Tc@ z;z|{?R&BLAtA5_Hd8wQ>JF6W>0sZOU=Epg85&a&R9RMJYHqEp!_7Ri=$ z6gm*~q*g`MwW|8L#(O_e4?WptRQE!x!Ae`&Qq=}%m$quQPUv$*@m8vxLB=QRv_H!w ztWckKg|&OyImi5&%EI;|7VCW+GdxE{uBNRvXGaGk7wdE@x*?Ip+5xWRl#a&avy`?r ztL?kzz-U~nuB!#_TELmKU2avIdn0w;*P_7o)oQznURD}?$%3K3NL!_?KG|C+J186^ zqn733TH4u>ooV;keFK_InlHMB2I8Y~mRg)ymOK4~F=C<7+-X(UHwL0&n^EHq$ItyV zX0cAOtKaF<7TfXIzfbGu&6$6FfcXlG4vdn7M$l4K4+i?l!oe#O`lfno{{abBkMO{p zv9aY1n^_;5emq)f?Jz@mT=6>sAJC@amh1tx1_K!|>z06I6;@hAvzbX9*?2SP8^c{L zeTSZB+(IU8IWT~=+H&=kl7L9WSKCwL!Tn)wMO*9UTojx^1$@A04%I6~48}M+za#7T*&LsK#vMJu92? zkVb%Qu%?`Px-P>n&-ClP;(G8SpdkW71n9_9I05nMh=6JY-Uui!P$Dm#JAdK}U#AZW9HP&5?kJQ)-jp9Sh5Y|TWtq(@PS&fe|VET_y9G`k-PK5k$ ziHReD**(mg6=iPb4@uqg1p zDquTcGj|>_sG1vYg9SOe6_~YH>#f>sCA1`pR$m;z;3$*rY9*ixX?-2|RNT9S@J6w= z79blbaYzOOYdn5#(o!8J$}=o6DZJ{gHd+nVR<2R{4%Qw!_3{D+uEg%iu^{)r?*VF} zY5D8cE*4uv`=_2dhGI{3(^Ym@)>g|Gi|w0^X|d7Tfvt@=Q3;pK+y<+wec%(eLTjyT z6zi8;_M};P?*y0cjVHF-p=Aj@Ty zCvTt@3ff#O77F*{Qa}y7$a$UhY6E`w#ngX2KF#k!u$>6}WB z+sxPAh+;U#s&SfDW@d!Fjk8j$yVD4m;_q#QLG8!my$xEU3McDftzWe=t{4Rok1Yv)I#ddpI5?x5LUZTg!T+w5#jnTCF?wW^TP`j$fp2GQJ50jrmIx zt5lDzX=Bp>=f(<4UGk-3yl-V~Ko`a-%4uwMPIGUewzWxK-qY;|R&=ZYRn;?WinUk_ zmMoYy9#z^RG?p*P({8!8WvnE7vd-0jOWHUnEyoDWt2BxaalF*gjzKYl&skmAJ&1mx z(YR?l2L<91#3zXh*;94{K{B$6(lLZ(7)^1y7Vc;CANbFoLI0jhYtI&&4@;t}4hGgD zAWu$a?U659VJ5I>4u(*smY`ZEErn;oI*M(OTQBU{dO`NvKKYqi?NXz2PBY8dt~Rf# z+F7=lJ4;%x9gI8GTIX6yy~82dCWi`0sxtybM}W@!C)r4bvLiy3TLE4neTRqIhhNL}pdwAFC882XWQQxVuKHr-a;dsY)s({9skJ{v9Sg=+m-=jV?0v+9K5yzX*K0W__#a4RJ! zeYE@<%W%;L@Dx+*`2n!<^)ak`L*fXS<^5D1B6#BMIav9IG?9fzg(0!P0(Ptwn}M_Z zaC*<$MRZLTOtUqwh}A%i8l+DIip0gVnh5eu{0LY_2jL=xZE_GnKoi0O^8n`uND_hR zCa`gY?D<()dw@_OhX_ic3{PeSW5USyx(t8?b;u#I=xoDySXp~e3O)cATT3+litpyx?xi&Hl(6YVURkj`-MMk3>33gw{ zDrS!lTtg2LEbK49aOBJxx=v59e6eF$xyG~})a8@#pwBlGzHg=g*cA8}1Bznn6*Lk7 z3r|LoWd%kHVr3Wgih#cAg+1-qF_|0%DBZOMSO2P zrNn0Io(*Dqj|Zedw*fHhgY*$FhCHPdc_W~k2i^$ikAV_-dEV-^5&?Si6qFJ`|u_snJ7cZ@Z^h*YyII8a>$1q{q`N zCNx&P)yiqjvc2r$zRYI3Mst~^F^>%nM8|`|UVMZ8%>z_8{jne8Qp;v9lb#9$>H1H` zw4RDj$?Qo%I~;tjRxq(C2}+Gh6_3??g+nf@UGI>*%?>6$NAsgRo|AjV-Vr(a>pfKS z3i8ErhS-iG7}gm&^|a^0I^u5kkE)AJuCO-hEL&G^`P~gS4a8x(ODmk~a2jh5a@%G_ zybM=$!qoPU(J9Mj@V1M`X*xD(bGB5hw{;fjgJ#2c1cmyje*~?KcJCP4wl#h~#>Hye zhxJ-#pfB)*o-i6dQV_#leb#S|2e)cpZ9ebeN)l|E&@l z2Ca4LPN=nZ0Kju?D*U!tTmcF5*$4*Tg4Y1|)&$hEsO6v-;;<63L z5{oVc7^Pc+cj?M{C_zlO?RKmI*&K$gO@E^>Xn*ZSGJ7TO4=jqnZ zKlKg2Z7A0K6CGwT?MSGd?M(0qoY6yBjvhF+c9o7ldJXp>+@(g{KLzm(;5bj^ppN0O z)?v?4#jWt{q9=qV3pJ_PgAHq=-m2Pn_b9G7YwG{b4#lOgcu&`_UHzS1*Xyqrs+-jg zH(t(m=^YF|J>2Q7;kYhBh-bN0aA;n)Xs0CpIechX)9;h?76bBStl1QTs`WB<7Enp` zL|cg64VF@*@N`!fq1x%Xx`h?Bu+`$oCMfOfEkVYXy&4jp@h zYc~WevkS#KNA{vyFYQMRTqOwg=*RVL5->Je{!W26%$}ue`4uxWP3~Q6g&iTZS1~*+zpQBT0)@_5)v}Ds-R%LIVU%M20){<4 zZlhP4Mp^qe+U(0Yp41l-)O~ULr=OgK5aw1tu04s3Gp)@@cN=Iv!mm|rc5)Y)Pj%Nn z&iX6D^)0m;SDjX?x9q4##}~kXk;Zd*Jwl*fk;>_{dIPuP_EAdPZmF@2PPUnMw@riF z(d+phbhksyP6W3jAj&6=pc}zX1h*p~wdakX8^KNlw|gM+Qj#U=Mz9mX?FhQtw(P}| zE!~cO1lfbR%Uag4+>LA1g+_l@mcXf}IF%3+PFK0WqTkPl$;{P((L^ zoycOq()6N2!Li)nZ|{UGBG`%Gb_6Kct0u5+i(th@uoJ=U$ZJ46G)kywOcW_Q5!~*B zSmBU{h79!sNEO{k*@@tG4}`QaY4Epihb$u4iQskwbc&F9NF#!;UjRFi#ek_b^3ahi zv3x~rN6?Ki2D~Z)&+W)V=ktjo=ti&;!EFK7GI89+gGVU-;1QsMM?94wAG0Gb3}}Dp zg_Fq0Vh9d^nWCD!Pe*>iGdMynPH~*8>Ko2Mj%*CISXi;EjMz9w_+gD2!5{CIUKU;D~^Z<9Q88 zC`Pm;mTy8vKx2o*k%boY6q?6_Zb%dT?EsK0q>X?SF-b2?j}UkC%6wHHO|g1!7K#cK8WoU(qIsMUlNd*m<10o3n6h2gtRefOc}zc$mb9N=E1WV@Vz4P z1b-WK`a}_67Chy41lV8S4`Z50z&?b;5$r^OSqQnrM6^<F2p=UQZH z|I8OjElt>}pVG|=R++Y4_W@N(L%$yiw?ca~Jxb9t6t0$7a*V>WTioK?Vck*j+4DU` zo@&Hv8ny>uFTpLW9mnkq*W6)Y)Bn!m!N;=aSi$o!&aucso_NN8HGIJo~;** zUj(z|Gs`;N`4C>LBd8DY#WZ^watj+~H=1@>Q2U1jdq}-lsl{%K%G`Ruu4s$rD#|?U z6XTB4Eo#lI2wO%AC_PK|&1S&WfCml)(_ZBEj_<)f*)~BXXFcQr*3bj2p$AyQ80C{P zB7K?&u!?%7&j2uvo`Nk7ISAOQCsME?p`rm`5<{X0FlLbwc(FKM)(9{ro`PWr0N)Am(T@rF#weCgUOy3F z4MQ#wut(HLvEgorhRqseLVwUSa}M= zD##ouEIxb`trIHRBLu)Q!bdTzLN@rm<{=F&>Pr@|p%MY(EF|uMKwww}-Ut|0fj5Gk zP>v@vSB6qTSOhCA#xUGGh2iD_1}O00d(HbW<|O1MV7WwqMF@!_pci`OM8LrG6h>X( z?SsI}{O$9NfF2xp`@m;=J0#f-fR5xTbc&E90=5J`O$0Pb;Ee$71xn;)WOz{`Kv+*f zk^mwvJB%Q90C4nJZri&rj#gaiYP?0H=vG{n_lcy=J$j}`ndYx2*xotpG`e{DTlAbU1f@_4habdZT z^Pwss$=Y@9wIxb!z%kDoM&9FQ-FLbfQoP5_fxgMPZ;J6J?-K7h=RN1i$fCs7_<~^q=dn-s&aTkz@Z@(hva*)0SrS0B|M6td?8>DK%#XWRlq#J3%Bh_6wNK@R? zC&YKytSzJ~u1dk#mVT!etlrA$B(--r)q0xW2veSq2~9}W^*y1|o=`D{-pH3>s7J{( z9X0t@cV$4EK7>Xe9psH#y&G@TSuf=`t&xAjYW7vi$nmt_Z9BI(n4;Tj*xHOYKP`c+?RIk)?LgPC+uyYDDpt2kb`hv^o51wXPU^rk(bH}46(C4^{-vweAD zryb05Zcn=TJ)x8RT#jitb$ZI(H-O&YV`E(hV`Br`K%4V?QI5Ow#b&V-yT4pUuQmQH z)30xX2Q7iOD_nI(IN24&YLsY1tkW7AX$MD;a#7z=L6MusLSU66tHSLodiuKApf#{Y zi}IHpit>0QrTFoyk=Y>rpiGRzB&LeBa5tOq?zfP4ugSKRyEqkx66a2!d-}*juEeX)~MN6mX%b8`neY^{Mu9m!DZLJcdYt1KJC(Eljr{mr&Jsj%i>fW^$+FP2F?)H~5sFi*{ z`Rcuja;Em??RaLpX_Cl_t4(BY_m{V~R>!|Wt(FhuX0 zemPw(@5#wZoZ6ea`IUKCR<{>tHRuT)rB${nbVs<z`FPcTwL+8{_Ce%+fqiSAu?_MFj(sE;#{9_NBwm$CLvQ2!EuwS*P zBtK@N&1e$Wur-Tj@D{vEXDgt`WA0kX$S&=`p_P(1l?VIDKE1v36#RZwDTV?b)H< zX+BUpsxPUR?bU^-vOQ{b@_F|%{OUJ#KB1Q8mtDzX|lCqD$|7?(g$T zpPGH8y+K}$Lyg8jdyUeoRnH>r7Nqza??vv_>i?$Y zx{dpw^&MG(O_zB?g>lavT*@n3X{p6ajt+=h+Hj>Vb081(D{jidm3f`yRIML`*YBsQ zU{Ts3oViyc+UM!}s+NT+lk~6kYaFk8$=4uf>D8}}G7s#J=S?4!uKp`K)^?fKD9;*Y zZ9;hvigqi?BQvF)nns%MU+$j0a!4~saI{bD)%cL=Bzv^uluEMRv|2^$nYm}bUL|*C zN+WXzX|#)AL=KebrIyXl+`G4qs&ib!XI`b%A5ZtqRU2qFpe{!>=444Dh5eK5Gd-s} zq>_ERx3ZdN*{3)z}RCFiD1NIkis--?y8~NvF36%Lc3)T{0_PBS`6Zql?)u z)lV|J!kU#?2DRBys*PPvb!63-6PNOle%UKgp3hIxDW5WWQV)X{3!&O=S#BCvuArH$ zr8sgH1$58gDx*g=8z^qG0IUoA2$E%F*SRNr(z&A=yIO0juS#Rk`1|v3yYc#8U-_#` z-~Uhl=-utjKb>>K)0s?uXxe2)07;_Yh~l(|oi-g9IplN|TnjQ3`}ciwx? zc<+YyD&E`hUd?+=?{&P__1>MFWxcx_{O5!JfsuQXD!)wST%J#sP%$&Umb=fWU)jm$ zkB$3(UzzW2vE%xqu#}>wU0$_6>hd%R!W?yD(^*A9$r~&hoOBsam5fK-C~1xzb+2&n zD$ySIaK@z0+%VdXhbd3DrY&L(t zyFW8Lmd$2I`RRkn`|eZB%-G?fA&a>`J2EyzWR71%Akt!I#}5w=huHgvM#qMUCv|pm z^4NVNxuJYMPi?Xja>mo#32I%;Onfblr5JD~CMJ@H^AmafM^_$YDTkksmz$qDoTV9x znftxVFZyQ3CupAufjl5@J#m66hMrQ)Kj{wRl0+3Znw)WI^r*hZH$$}?1k`nv3q=MpqM%CA;{6mx?%LOi&~ zWdz2@v!mlmMw8QVqysg+oJ_JYI+jf;^s4D07>!gTxzv&yNt*GWSeyB=p`@mNx#?js z8+V5WkGM)uMUx7QAIjyEjv>)tNNV|fZq!p~3-WMk z+OeFP+WvAm6JvZ7U&BH{WP#U)jT)g)pp-(PK#{nGx8;s`!S}fx!kxSRvugU-SLY7F zf{OU31igO?xd-}z0rawR^`(xFSyicX((&raAIjyZrBTKo$Q@PH2J+tB&5!dB${kUa zUV=$41=`C)(=A*hG;&fK&1J<}@Jc03Kt!QQT*~qq6+tafp{cSIP?^rXY8rEyTKOpa!%qqzX4U)h~O+DqBH)|c`agBn9l8*Y# z5ZOPKdr;{Q{QDXdyuWc(E`6?GzMKbh(?}!nk;mkcBL%B#c#8Ol#W|9jGGB!7XI=bx z#u}YX{V~hb$IwkG7V)8DIWsk0i&AB>WC}INtZNkSDTIMv!Wd+ybBCtJ&mJE4PO^0N zaIzHedGPsce%kp7V`SPfNsQ%kQ7~)u%4>3mX*A@~c=CW=Vs-N)FR8sIH*#z|f6tNK zk6^xbe=0kPkmluOhK^+>@peB&>rTtG@k5c%JrEfS(-4&E=Qn`?e0vToT zQPv{4>@ivu8^p|Nr2IGL#*Xa%=VMt59ohXXseX>p?&b0RCpGY6yowZyGB8#N`2c1c zhL2?OXnj}wGI;mB`sBW|CT>aPm}jKP8}Q zeJV3q!27TXlD9eF@X?G&)RY1--SS4pNbsV_USwtqv~)Bhl|bpK@WtsIfn#KTEYCmR zCG)Duk=>W52+C#>V9~=wWomiu5!8aNtborJkUCH3ewXBi$oivEUbL^aIF`vX$qePE zEcP=QU;RnzMTC5FS?D8~$)gMl>s(k9X^=cL0Z;O0^W$f+`Ya&EDRa`R6)~9Mj!lm8 ztHY8>v*=5`N*nrQ7SiN>*7nr={(hrUf^U6PCLS4{3;EB#=jfMy?ZMyuw&Cs1zj1z8 z3+v&GoU9zyt8Q4t;So8ba&mImjJW&@fAYzn_=|u4vv1EQ|Lei$e&mDCK9+yw)j$5R zpBeg3Z{ydaGMQ{X# z$A^FQw}0TV;cxuyFTP{=vER#_Qi^woMb7Ty!>?D#eBLQ1Gkp5bN6rlY;-yl3Oq&$H zsT%9=smA(Ss(jp0Wkxbz8jHs=Z8!WS{&qidBD4G5`)=tBelO%iyt>o>u1N127cBAa&$XH9CEmYYEprEIg_nobA1jyN zT$($%wpOmpS1Of9=I+U?WDhUjtTxGsw;k!dRE>IOX!O`fc60=fHD!KiG&!Cf&En1Q zndS2^pSSRNTXu8;ufu1F&og{(@Tu_G;8Ww%y zBrVP_5Ny(^ya9gC+vgiZF{gV;+viIvKDA9%6^3NySbofDxefoZalv3&WQns`&a!W;j;3QPq!EXNrwO~_atrCN51$s{#%?`RFHjf(P^wLE5%S>=J3%vv6^IV&bPZoXumT^K!hvPD7z&_XWwp zDqF?jiEhA(tFc~H#7v9;Si}qwmT66uMksm6I6hsleW@+Y3u>&I{+Tc}6Q(&{Aj2lq z-OrD?;iL&G&@yXELfRI0_xq`eLLf;p@q zEFT%Y#B8CF4v9_jnspy`&g}M0%r1)|wNKZCdB69&q1n1gSHj?Va9qF&!(KWWO~?Kl zhn+X2u&h(X?pT&=!IbkIf@Fc`!Hj@qaYq8>ldj3twNXL_mX>W_(8(^74!QH6A29L& z7u!$$jXbIr=QPlYM|$}P>+{C+QunTgAgzY%R+i+mZJ6&76|aC^$EXxsID8l%cg*Md zr~PQ&$S88mjnD`HbV^22Egn`{o(dBqk$GC(4je?7VFD0gRx^PJcc^Otg5TmQYC}0n zSsNM+6Ru!=Mnsi4WPM3}42^W@h^E|{HQR2|5rYk{q#|fiCpAIqO3V{Z$0W?4LsEqP z30k`(cP4?RPVXwlj7ai~pqYZ?Mvn8sFiyu+fQ8AOHivF*&0KmgjrKhMr8A_8%By*x zKF#O|0vj?R$2sc@wH@}AQN@Qt6BzV-Y72TgGEC4Gc<9?4())y%1PTZNL&lYN{w!!* z>4Lftua}EuqL7s%cl=NkfHH<%rXXj$AXM;8pqkVC=FXTqYp$nDBaB{iyXIm zDCUAw9{jdX7FF9<}3& zsaIJ<$dHhU;{b%+MsiJk;u`-tW(7LQwQ(;#Ec}~rOo26D7)J=CycUBBkWCPTUf_|= zji@aOLi6KEbCQyGe=ynoVHV=lWkM>T@ZAq-yiPILcYlzU39(8^!;liy)7RCvHjB_5 z({a$Nc(fD`YgL-h;H6CRQ!HzJY(mHXS9?cx%yn~Y(ecG{d#;;fY|?`MaNED7TV*>xv%F8@85oOS}(@t;Hci7rle_JGQoFWc|W#Z1mZYId$ZmMR)4V7 zXlza|ylz@=&z$Bhl)RgIn)kL$pTDqt;lXxirhW>w9XrRwn`_;jm?K*4IWau z3)K?8(kb1_MTNNhQG%(Hz&B>xdvEHOJ6Ez3yr{CVebIlzk9Qeo3yXDLYQ?(|Dq_3v zikHQvj9*4>pQYWu8e4(i8Rc~un>~TE2uxS-ztw5|!MP_FzVA0(=HimdE%7|xHChn&P;>2`V&cNT5q%DcSTs~3NwSt+^Y^QYz?d6Nr; zJP-@9iu}vp`oU*^vA-idgTH|V%cdog}$ zZwn0!UwS>+?5}b!@!x^JO&Z8pL%*8y_`S6;VLipPqdKh$- zoBFG`XZhm7vRibQ-6g_{xaYw7`^@m4eNiLMcgVi~xW)P>0%mwZSl$^v8FJNap>y0} z(p{rP9unwgn*P4Q4dbp5tJ`6`$ja_Ag*#`)@w3CfPo^1U(p_RbVBihW2Xgwf@yt?h z|BoLTl@8qV{2FnFUs7&xS4#KsbZZTB4n>lvOx?HBjjTGcER4iqJ9R3FC4x06M1Xax^0*-eZ zMSH8IiFG|gPP}r;bfEi?eI3O#7lYhNJIlFMbzX$S7|S-gH@`h?w^mY&|~Cg4}g z#CxT>{oR*ve;FsJnQnsV@7vum(q5np-fj*}+HRs3|6K;%C3fzkXy_sw`tH*-{Xx3Y zO<8|aQQyhX?M>gpCGMB$I~kH#f2U2h%OOAY0F@H;67Lj zgOmZkA`lX!#OeqwDSqVP#;NQo!_BbTI<9NGiPPAQ(ki1)9H*{394EHo`k{{DI8}xd;q5S`qS9-m_ zEnEC|LCeL(b%8fe{mf?XC)a-Qj=z2AYk%>M$KUhZmD3-*{K7Y1e)r&(^9`K+g|Ue*89|r2I$xSeqL~C$A5#~JHHF2_@8Z?T{Ovm zw@KVbQIWX6jzrY|{qU%FSlTYUf7E-t#un|L?)6q3zAF1)<@WFU)=S^{t+M}}Z)^I6 zzBamBGjVz6eV4CXewQ?;HuN1G{W{sY|1OBU`z~L4{|jOWtv%7Ku^ykZdy8|^>enN$ z@*lMId!yck|6D};T)%fnTmR(6?fvO)ueZMUP2Bd6fAULuy+e2J4*vQz2|3(*L(@K} zcZGK7yk4(f$N%~@858eS?MgO=doAb9)8nANd3pkyGh4msNmP0}_m6s?m-r%Ja`Pwu zltvctW&uBa_wlVmP3^?iczTPro!nYsZ7MToyg539JiK;$bEuX3)*9z~5AALDHb>K2 z#cBJ{klTKueco%W1Kh-T}i6Hm_98hqZb8hy;>ut#5Bm z*0r)SIWoOd8g{l;C!4Gtoor3ErgF%xPmWHGYGryi_Qq2YwLg{I?(a+{vO7iVXfj0o z)lye~HQL!;n{2D=&d%KuZ=(2Qhhaz4J+a=oN#cjVvB}02sGQuS#5=p&yV9?Hv>YaI z%z(oLHnvqCH?-jG@eNyb4oSfVo-TzfORiwCn9{W#SoWf$~=FQ>c=Go3I zkT!jdwA}rOggW`u^c6E>&smynp# z!|7{L)A`#>--(;OFq7H4yZ+$EB*tK?H=dr8rf-qsr9b__QE$Ala`j=!0L$6R)vwVV zlyeCay(hK?*#h4o3x9sgQm_8t+G1pFu$y?-r5cx9B+kVmrTo0VkLR z^SfN9{QTBq`fki`)=tWCu1CDi;NA884{jYAvws^~1Ap}4SUe|5)kCqbN8n0z9B%bH z4vqT)2OhY6)pG9RdQR&1WX{N4gdDfKe`)>r#{RMX%3m7aw{cl+u|9Z3uIcL^(scAc z_1wAN)#F^IFmCZCBL3(=P6LhnA8yYII6%*R5!diR&)cG-IDhuXKkx)?59t9ewr}bW zuF0*}us^)ECL8$ndvamwtq9NH?09hezK#9u{#Dql@88tF3aj<~jmtj@kq)n4yDuMNG^r5y7=eM3y@TLD3EvSIywe-JGcb7C8$3;e zmpnx)fE=xg&vZ?S%+{*N{jfKD-Iiy2SuPjN7|U53_qLP4x2?N3fNlz`f2` z%pPVKx=-aazQ6bJ%ReRAW&0;^b4O6CQ@QzB-+QFbEK$@{^ZIFNdsvb$C#PQTzC0LQeHSZ4ZYSm5nPyVR&7!7X8+3XUu^45hv^2qhO<;flXBIVb zdWkX2$Ws|B&=~6^D#jMMnLBODRK+DXsailix`5-2fR2aRHcDF$4+I_tU+H=fFpm$l zLeT?Xzw*&TGAlw@y(8GM=5XL`eoZgC-X2c9&8SjNz{&!fN7(Oq7%icZ}Y4A zO~Avcw;5I3K8AUhgG6Vn9Gc0((?s9JZ!U1PA49=dKjE= z;B9{Y-j17RcX#zFab0eEhR2VyF1IGby_sC2QFeuJ)!S2aTa&P~Hkii?bH*m&QS>0{U zJ$<>)(UbqjNu@Usu+|F9aR;FmKTV5)N|GBMSPit49as*ud`Dm{cRyw=J7)dPz*=sF z%vyHL`e|B!&MU!vk(p!9>|5zy>^hWAQoi1Oblu`kzpfbuy5T0uQ{g5G_oKLnlF29c zP%@5s4}~r6pj&NY1B5}m<-v76E#9oHb;rb7X*1s5WDFbT>XY>|MAW~{azBkXQf?l%D%a+ zyoVK<$jz8eKHQyYMH+0zw@MQ{syi^XZSKG{U@=Ct1;#BI zoIF{%pefKqR_zj*#wd$2HU(tfza_&qBvYUwqp^_{W!@Mt?#F42lFG{Ta>;D(@2rVE z;_*~@XsE6Sy9)2hgMccHJS@~-X(l00gPe*y7ji=K)KrwPQvo3XBMjrJQ4<%Yc#31! z>5j5=gk7-Agn$uBDDPpRiV}sJ9%|UChjh0f6`{c)V1z{pJ7viVm8emp1g6LdspyV+ zMMwZ;0oW8I%L5jwC{Z}b2~45HyQ5waQfSaaA6Jd49x^W7QHF5?Li!nDk!S&{Py{TL z+{Rd%e7)Sp%)_d%eXf~@HFyNGf2uZz)8O<@gR^64aKdSD!a;D;+?*D>$~_ze_i!5A zjH;XyuyO&M24}~Dv@k4T)WhI}gWwi6OpAwu;2utcQ(&N+-h|WOgwx=rpz1dPqgViU zf}7g#Aex6e%ID!UxS1Ad*3_OW)8I5sgR|eAocrK})8K@I;66qW+`~a|52wM+s7ea~ zD;L0NaCR(JP8bwnaKb@w3tJsTRO{g&xQEl=j1(xRH{mom;WW4@sQOL7C>Fq-;1)1E zi00vr@_9H7&W?4`;=Ku{!3n3qEmG;XHMRE;X>ghb!O05E)oh`MhlAiAPJ^3KrJR73 z3*a<3Wd=ECSnEX?oNy4_!iH(_Fo>$)9!`T(V4$4dX<9s-2B%RP++u{v30v^;aH#>? zccR#7o}{|(L>0L4aC+n6R38F554}3GZakc}5C1sG_2FM$5Fj;tTXf^$w7uihQ&9VE zMdyxtSPq{-^=uq@Sg0Ehr+VVHbe3y?ozcTPrFmGhoj<#3&Lfx?H^d9M38%pcr@`5= zG&tcjIN>0;g{^K0m=+HQ!9AP?XU78N^d_7JC!7X11y#-o7{vm(6Wr8>vt=IcD4&PZ z;AUE+SyOweNQ2Wf4bFada_)l@PJ0%9fNC$2^DtVfr25nb1M1;mKs}rWr@%Bg;WRkm zG`J}UJ4QGSPS}D!zbh*baL0FDe*Kq~2gkW1zdH|Qb=KgT{5b)Z9>}r)EIp7X)nR{g zcukfV$PxrygYebzo3*Z?d`NzX?yNzOb$`A6yZToVVtxOf{#69g1qleF3lb2gUXUPO z_$~acvc-Rc61QERAge!n`=><|0%K_c;$n-i*s40WRAEn+D)gDG&r**5j1M=hz)vf1 z#P9bypMH;~1Jy6<6ktBkQg+}n&{B5bHqi1NfvYT9Fjv_zSJ^SwcLuJqg27y6$6RH{ zT%V@vAA7-A&0xORGiU6XciUgdD*k@pVI*Dt>joO3%IM3w2sx&7T?DpR7xCR#MYxM~ggQ~R%M8g-(-BrjpkJ7$`0w&7 zvQXmYPs$$!$sZumO_xS|=I-NE*6f{Nm}c)J!*Opa>u%Qfo{&`(BV95v;xY=IHE4Bf zD@IdoeJ@&ZJ{al`r$c=XBP0%f-9_W@;clyoSORK1zc6D+2(Cy5M~ah_V?tbVuyg;o z_m!FrN#o$^pAl=nj^pjs{x?W@jB_es01>v(@#`9NEFo)y~4@(n$7vA{zs z(tx?D>p8R{4VV_%pcQGr%+Lm{$V)YjILflFf=vifE#gq1d8wuh#UJ4p7%O`*2K55t zRv8$muQFDkkpW3qgh6AJG%ihnGVk9iqko{x8@I~f6e?4oQbuD_R@RbuW5DPJjZv0v zuqjaH{aa<&hRPJEl+id)=8aorG&W_W8{QZ&xgBjDy59OM6ZwA)r-AQFM%a`)hN_Ucheho*So{ix=VMIrMny$n=T<>dSyVpN1#OE zW=+#D|LW#mm*`$~hBagS!9@xV?A4?ctQ$j6!b0C>Ovf zH@#DC4oQ%6`VmgK38&nIjr;S>8wkEUdkgK&^*SuZG>jDK`@mj4Qnfr`&{7ZoOkSW5ZcBve72*;Ij z7!c(GIjLI42#x>(EKO~h!yZn#&8SG@XxJI5%G5mE;hu*(-19ILgxqGj08Y8-opKXS zx#72<--J_c!YMal<1X_{MK#wx&HsXHA3MNN;vjZr$UK~?rMIbFDn}ivYu`Md^-pFb zVQ&+r!2kPF^)_jwMx7-ES}n49I$_mT;=pB31Lt2RF4P&xBc~ z+!iAYZZQ_XDK`K=LzX5|9hyW!GOoea{q}uGnkn)@bEuXu$_gf+qu9OQAh(ADZVwxG z*jF_TCJo%IuRb8^p;}4{#+BZLQ?-OmZOOCuPPyT?bX+x3aIxu*EBP2ORZBQkOE^_a ziZoM%Q*IL&4g+D71*ROEO+HhS=XtopJrAR0LGYBDW8CSt4^BAcCY*8;HtsUrl_I#` zP>1Rq>E&2G>?`3^En$=kyiIL23;-645@F+B+E;aG`U*E3+Sb4JG?uC*AIhpTnnU+< z?*iD=7H;nyaC_Le!@jC%a2kV~^_2lFY^c_Z7Qm@mdYjs+ua@&LIAO$C0H@saPPqa2 zIkhyA>QG(#Okq1|Qnlnu)e=To^_3KMwpsuO+}=Cj_HfG0K@84Q^d_8g6Hd7ar`#qm z?6)ac0H@saPPqa28B!*%`BnGN1U!s-D2>4bZetC(J#0A-C$E|YX92j`S-=Mj)Or~8 zsxyQ+h&Dr(%29{#Sn>g_eUFMg@SaLH1H%#|=zEmw-7G#RB zsa+~Z9jf~cFNXn9hRGwGsQ zu0CL@){H{6gi$VlO>Ie}`Eizo-thBtYH459p*lx$G|0z*sanD)7syF7MWWz%CAaAa zgA-1<38SpMsX3if9=3jmiB!{I(!k9ojSqvuRoN_aFDL3Jin{diaIOS&dQf}*gm^H#FH(`|YmL=I>YQ|XH##p7zChea# zJu52?@K3_FD-Q54xbn|}im@{0#(*ud^5@2YVa!3&OmvMEsMf60*fbY^P%rp9fmRv) z17)79RR$-YvyvbMDrGb_WuY-(^qIyeZ>;mTnc1v2=`PMEtRvP1IFveXB0xtX0yOIi zlqGn|(X8Opu+0iSm|_VYHIUGhEOpm;RNOh{fnEZpH;k)BjVzd&EV`pCD+8&Qz!JSa zZdWh%n%Un?BY`D))hN_Ucheho*So{ix=VMIrMny$cDj>a513vV5f21P6b=@~wo1Ia zDJdPv++m82(A|Pm9WgirOm7v+loSBAehfp2YE-RRr3SjAUMV4fG6YP?Qo?Rdu(wpN z8iji49_S@tdP7-0E=+Bx=#H{0LlWeDVTE}2$U$iV9t4WQ&KvTxx*A4@$RTsdUbU55=tP9t439R8JF(X zt3pU%iC!PKtC#M9UYLdRLG?6gRHINY-A!-UUGEN4OC#M;R%s*<=w+HZ#|WEPnv#+^ z0s)8lkz5|eY_|D&c_^8Om9M+x(Hkji4nsj09ATSLiM$D?+=NqZ!YMaXuNjWG38&nI zQ*Od3H-{wUCY*8;PPqxAta8qT*tji#18(mfaCiW@+9wlzOLN=y5y@v7f=d|%0zswIrF>MH?8 z3dYsL!MJ)j;Px=;AvcFTfKzUIr`&{7 zZVpK&=O(8;DW%->PPqvicbQ*G5&TrG4%Io*%i$oVY6+)m37guba@3)b12-If^KxKS z4V*U$)Ot8j>tRz{xILV5n^7o@Fv+EtAH3r`+&cI<6Y2>8m<~<4QgTOw|%b8K#18s+JUKrU<9p zCNShCjIzL#W3$O;O7c7pcev+av@8goa&wG3{r15Lr`&{7Zo@-9h$zv4TrY%uYIkXswE%Fsxz8H_ug;;Y-$U) z_YSx{Y}{dA)igMb!Oi;0fEG4XYeoy;R4u(tZPi!Hc^I59Vl04DZhEKO0Q{Unv;=P7yI0@~%_vk$ z807-k)Rsh=A7@$U4L?7pmiAR0s&gbqgM18_swIqaft)l`BnpmKa+{7YIN_9=Fv`lC zn$tPuVe5C8NHq;64cu(f_<*P{kdvxqLM&5D<)}kAu9U-oC__1fQ?-OswbtV>xG7iw zr`+^TxdHeipd3HuSKUw1!^0^zO#^OY4Y@rW}vJ;mNd$TNRD&wsO;^%<;02p ztUtT8KRcM6ltRBfGU)va`G$dfsbtdMJ$K(=_uSrAe<~jbS?>>K=WgE`uzv2Qt)bQi z=hnAIti5LA^6L)uK6vHzV*H`a@tw`55PA=+ZH}j3CXHwN$M<&E_usHJ98b^6{v%tX zrthXiatNn9d+})FJHEi@wm2Wi+N1MaV(dKw_Wpnk;PyVZ)`r&{3 z`5%AN=Gye{4do9b#P)FSWN5p&wz+=O^k>xy-+x%|eR$mK$rmXmTjS|1qHJ((YiotI z>CcE+H$|(vkK=0>%Dp!IyMmwXpV&Jwmo`2R|p-D8sw6v%7QC{;MWCTm8vRgV|*JfN6jK)?ji|Q=J{`>`ZPtez0@Xsf3(@V9 z+Xv_V>&fQiw&_<%`}C`&*x8)iD$uPG_1s_FT$^mpcD9 z%^EkO!SuhCnY_DmYIoHcyj7)33-Y=zU%SHU9xxARnlkq;a@7dDT<<82cF7nNFsY(_gYWneLpP+_Q7X1f98i za`)ua`KvNWlRI|L-+ZXo+gyEkbN$@?n;U2E+T6J9SWz%vdExI!cO;Py-r0wnI37QE zT>Ng|+}NGmF*%)h?C!jJ|554ktD7$GNe}k4%cb(@Id$Yplp=H+|fkA#Jxl5k3at4|Nj5H@`@P^1EXV)Kln>x zw7avv|Ft4~zY%^`yw0}5S$R|>G&QI`YpUC7qk3Od?@#tc^^Yx@hwX$q%qetgvbDJ} z{W(ed&Y9Sa^KV1Vab})u4aDQ;;c@5W&dHg{*7ng!JJ)H<0{~`!U(C*qc6TQGk8>8> z`zM$M`={F}Fu8O0{15%#lGKgKotmoM#~=KQvcJ1??>^2Y_v-0Ll6Nl$>wQm5?v-@h zJGpmo?l&hBy^fE2ZZpu9XXv_KU5DrXu3QcIoul{2R=#vGIkvvJvLRR7gRN0>wcQ#wmHRGi4OdAh zmx5K2&W^0DJn$;H>d9oE{x?YKaC7bKkbhR9H&@U7jj?=d zYTWx9!YOw#a$oYAy9f84*<2OXt2ZAT7O|y5)q7(2Rqcg+`_S|s2;KUfyHEVjpZ;5e zUw-4t?C^N>=<3CnNAiuWBNA+9b9L?B!7clTZaoHoQgF*M8b7&zwoS<&e&k89y=`;# zM1N%G*e>(#-mJenm_905?)L}NuaoUZH-gW188I;x{{->dgH!=40HU0a7n zBv3<4p1a0dJ-JPptsGPmW{k!8u{k{VtDD2)2k&n>_Tz7yjc3~DVNXutJ-O$VxhOO7 z(3oK^Vdk7#+3JlY_G*dUPL+cVPJrRLU*8&xb*8U8sds_G^fS0G8~1)+&bM--j{B{z zmo$sm2ftIAw#Lm=KfKkGnO1_!-OFfyx+aN{#>uF;R93sQi$^9SIh;n5(aO31VeQVz z@7?>+|M0JV%a?US&0hbVTPrKgDR6gZynlNWX3VH%bf27v=AH3mymIb0Xd-FE^}n(B z!}A!3ofQO_Kvx&;^`4w4K3N%``;70=UTXV{558EU}Q{rAW*KbEVs z9`r5?J8Qc;>-+l?$-@NMnXGpc(eBPJ?yODLMcevhedXLgZLAx46FE$udU7H|w6i){ z9iRJtolWEOAAK3;sSUZyA5Av2pIa+Z!9C8ya_iI=MH~J!bZLLhrD{XMZA>=C=l=dc z&#jxhAJ~+;nsMKMM3TUHb$Iv4{;5?tbT&t`wKA>N zc&?mXJiK=NKu$w48ge9IAR5zlc0a`fV|?z%#>X%24EHa~z37n6GACq$%V6I(8Ey3@ zV4n@@ws2zg2B)kXbic;SPV(%5S)|Y?rZ*nypb7PljkIS zDe_)yM%V7cM!pm&cca5S`Fa~r{d${Lqycu?pcQF=hc;+M8q^ivh|>zb33)+sDUXbk zE8ifZ*TfP?Wy!$2cfk3q;!Qk?L@6G;EYW6R^*)OJM|H_;FQet*|?>;#z z|B9e;=HI(#t2dbbs_cmVK~FlO|4pBi9x=Z!97y%_tH1FZe`MCQBDdOl&}l}7gJ$HJ z&3oT8_%=CN_0JyZ4}Js{^2+0=oXZBir{}o`I>Ikp6a{C$(y&|%kh`L5$j&4@fU>`#&M$431ra{Ab&G$3n_$pvh1?S65TsrOoJM%T{CX75eX z{HNLc2JB;oeku3m>y0pz=X{-^@)S?=Hxrfvg5Ngn_omX%UCfcQC*3`;rjkjQ3FeSja1{guaTaSZ;}2wMsdBr_ZS0S>R*M`CANQzjRWO6AT&^e zgTUaTH@FxW%)G%YFnG!vJVk?-yoQ(MwIB3R#LLYe{&!9K9g{x)ZGg{oq73vCp)L>_ zB17Egbi$#5hF#LGRT%3~9SaSN_38eL7$fF+I>XG}?!RdIw5jtAQu%18e1lX<{RSzv z_y+0kV=#B|ZzJdo(o{{?<+a4#FI@h=NlOK&M;CCs5zz54+eT^Y;eo)z;G&96RuU*lyOBqo5MjDBH{HzF zdHiCzQ7RRkRo7+mw@(m%c)r|L61yOnp3ddX6i(-IBZl*|y#8swH7#duo{t|INUbmb zm$$#TBk1kl(!UBf{k$|B^&Ab?`W*d*L2;I26u0EP{S(3lZ=9~-jIGkT)Al|1hP3ut z&fCwEP0rgemkq2h5&mgn1Jxs^Z&(bplpWX&v^)r`yovSZd418X_4o3-qi^(?TK zv%Fc$j#)oN>(6=JIOUr;_RL-`0GIIZ@N^ye9S7NS4t+X&vBSX2fg@pqbJOdCQguCf zeQ=&^aGrX>2t>*S!@0?x^EniWNN&iTw~7QWV(yfDT1>XR6mtNU|;XcM!gF%nepG{SM_@zJXtO~ zaIwn{1T}l|jcaI`y9hmo&sF0vAL?0Ps?%N?-IZy`MqEG2FmnK!BP36ee zl-@X--8h@gpCxr>-8h@IR|_0GdbNxA(P5)ocX8PU_zm4F(@=!Nl zQR3@wm0@Wx_n22&Xe6r<_~PwhsmK$TM3)CW+y~>mo2+B;@7-Xd&vdY=pXoa9-C&6a zI1T3>;PBq z)u(Chgp-Fx3-?qvT#J=vhI^_B*EDy+z1@d5I+`;zjR z#-yMIFr)zNaSL>V_B%nF*P110i@scdCaCnnM!UiC(${FF@07zGFMW+joAA=NeIA#e z!{wc?oQ?E(99uk({~bP$Kg{7`2BwGk&i83~__lYxVEe>_?K@u(aj$ykOHqKQdXjoV z(({b$y>D>!UD&YoXQg#jcJEy|gY)qINH*8c^PltWw$WZ5u@P7wv9%%%;LrxGlpX-) zL0Fn#z$Vd^%lD)4X| zt%un*N+Y1dVL?I)%sG=A@2vE#Z6D_;aNxb`fE18?&SxjmeEo6(gnHvuaP zY#zRQ3t%{eutiz`2j1pa^_zf)Q*Se>xP1)sE(eK@QgC0PO*f7kdEjo2=D9rHIBweW z=Kqh5oBu>FTw3VW;`&#~$w04STChzP^$zzg%S&k8z4iz$FtVzPdyfLd&->auyr%o- z`Y&RaSGxzeGvGz1g)sm23Z#1i!Mk{2$GjA3y$I>vOZ=68ZGV$IdyKMo`Mc%9f8_zW zrTCCsn|JOX>ic|ITtDegpO)73{`48yY^;ngjaRN5!eJqIBal6~ic)VXa5th{S5db2 z85)nj58SHvBXU=w_aoTie&h(E>2J`Q6*?cm{m9P!qn^;}f0w^s6yDtaE?^le_a|&Z zFPo+lE<;Vz$)?Gy+^x(XwL{tKNllCE=e4ibzgD8QZHD%GMBgT;{`mKpROIPRR`}q@ zKUv(P&xD71`0Y=h3CquS8`9GL_6INh?GJwXoCweQIWha~58ilWgfq6v>K+~W+h21Y zQ-N1i-&Gi1k^8fC+^)P^w*9M@w0u@dp85D|A)Mqn5?-=_oBUdcfbwf0uz{BH$Owyp zma+r8ftK=XA*|&tL#6g-F{oLDr2V!wpD zB=Bk5^Xf0*p1S-JUIxWE=_7j4;7prAX&%?91fg3o#Z-P@H$BsL${F^o{Jw6A*d*6a z)1*y!n#c8+*U#JZ`YBgsy?$bg>*tr^%%H!ogM#^`*y(2Q`}%KmeqV=6xrAyi%hgkJ z&OLXP)Zf?f;4swZ;i;aN@cTO2_`oc`qC*lMAlPa46I~-Bw?|jyDuWjU-^|T>upy}Z z9roTTTl^=c@@``8t%2Mh%(Y2xt2RcI7YERxFAlUK4UnV_T9F2*(*~``BN8lG>b)CY z0I&%jY$}gP3N&vEG+;4Cv<1d38Js*>iL5{qS+z@K8lx=A*c6a?|CS8fkW7J!jK)S* zlzC&oNDz%tQrR=bM(LIOq@##N&xE=+N^#ca?WWr<6rggb;DFhU9C0Si@>D9m$Y+gn(b9@5={ zR7wmE0V6C*(A3N=9kx)38Z|jVH<1%k(H-@QkO0a8uqjBE2P{-kqHvHCm_mtnN4+AX zP#c{vt{PQ6WL&zV4C4lbrl<0-fFWR^KGaS*OI9;R?Q60c;CPg`N%OD!L0TA=FzR7&!a;Bg8>Yp> zL2wVJ!6`6MPH)0#aKdSDQ&9DrfKe=fJHbt@KcI^DJls(}52wM+Gz?BS4Nf=>&VF}t zZhYh`6aEny>ZB5}p zG!F;CO>;F{=W>hIBVC4ch4NjRs&KcHv5e6q51h=qZT09J*D!7N!;1n1r zr+1nb52wLtlm@pLp>o0&ygXcL!1nh$>@>G+-QVvNxbbj$oQ@(u&t?kTcsSJ)w`Hwd1MG|*-YLz) zoonAbtU2#KH``^cy0Ch0GYX{!h!=VjPL&f5f?L>7xrc+`9!`U^V}Wvd6HbE@ zPJ^3*DlG(zVgcL7@ z)o%huu>kG_w}9b6G!J)_&%Y?@c%jPB;y2kxIX<>BhsUJ4%=qOK0`odmcth zl~kYFU_d<_45)|G;1rk!C!7W+oCY@qVaEuk!3kUNe^RZJ()-2h@~^YGm2$e!ih%sP zQp3H^;~!|%{cG|s0^wh_!M_vBh68Pof3a(GjSUy!A**O;I=rR^qifnMi{cPKA9S!@ z4m$(h#kUYTVvz2&5ISLy?g@nWB_MVzph9gxLTx~W+JJ=GfKXZjV#fk1)CMHf22`jG zNJ#g7+5Sxk`R8QuPs{c8kFn>nxDU*-f&JVXzqQ^uF`i%Jr=wGiqOAMV7FqoFHmnK9 znuN@s>-)!TgzM+&$pHT@{)MXzVN330L0;h>^!^Lce>>JFYC5OSh_ZDa!P9q1eS@dE z>AR(Vc<|6zto!o+ZTUYUqxWx-|I1<3+kfZk%6R(X(H@H<`%kT`cJ{w!RsOL%hkxmK zwX?s!BL5AuB9(Z3wnl8}iMFu4_XOxv#?Ui6Dc_a?Cxa<$JY!pOo~L^`kGbKeBKXtg->AY!KrTt$vRt1MRr13Wec7 zOWA?>Kug(y(?H7uy8gAswe@NpTH9jQ25;lGAv@7JLS)IP36UKWB0DDJVpmA()jG7c z#f(C*;1toZMPO_ZvoY%knRy|yV?t!dgv`1^TCdijwJl~8g4L)Hf-M4LiEwy>(x56w#AG>uv8V%u|;5P5wkJt2$7YjCPa2ji0qhsS#TTLi`yF&nc#_LCJ$R80x?ObYf)i>y%n=pi{3f3|Tip2exyn<)heKt);s zfmRT^HSLP(cSOma6D502RKF6{dSVrr~60lPr5qZ{uDjZR?2zxs=)3w@javNq`H6u7TD0fyJ;Y4HebZL|rsLxc1ho#H!kk zP;Ky9OK;2ka8a5Syp3q{jj(S#?BJXCb_Y*o@i5d-S26=x4W&mx5F0qc7K`ob|2#5z(Hk>IEYhu7U18|$lulI zf0utk?(fRq(C9#wJ3E~d<)%(kUGC>}+_>lNwVuFJn=bnuaVfCAT1Klja((b;8IiZnp7HfTi} zpk5oaA`R$>HfZJHcJvXTECXffpM!04AfT{E6~W$ zo=apJqoi?Z3Y2;ORvG;RW!|_|2B%P&0+livo3gS5+8YCI{w)rP45V~}{(&;@-zvj4 zRHi_sjK+a7Z`>-Qu_-Iv@Wz1A4H~0lH=gnHtkKv0uE*Kt=3E3QZv-eu7l7iBgMjuo z@-U9FD7TsLIO*`AtodbE`&l~0xTa*OJ52GzFguN4pIXfVJK0JnxR5(O(NZ)r&2-yWeAv(rG(v_ zU~j2jH4638JQ?bKc#Ql#XQXFhxhaJL;8Q9bLT!5(wj}QI%iD zrMva25E59T*T?PZrF)^z z8adF1pLaM>JfHQi!0dQBr#u{}^)TwyuzNV=rodoa=}kE0CY*8;HtsNyO2jvX?HI$O z8du6;nW`n6s{#2&ZZZo7$yv)SX`99Qrt_ZvRLtOv7`e1ucA7Afou zVN+Z3%;46g(AyL&fMK)%PPqa28L~8y>d>48kS<(bNSr9zs<`{SS?Sm6exe2G-gpIpQ zcclpKH`JlJ-|%v*9`==Rs+KUy1>UB%8U_FhMv1U-FYT*3G<}5|4sGjSdm2mCk`HCo z8O>qyN+1|l51ZP;?cspi!^R!<<)(Ma4ZzQkGI`Cfx_>6%Vbnuu3?6VBYsl?k%Xv6?)igK@z|GDA zK475M!>CuCAsrPWImU2?H93N9#1t*+x6Hd7ar`*(^a$EPp zJQGg238O6DYyux=Q#GgN;ZDEj;STpajFym_L(=KD_a>Zj6E^NLzcj{@n;E!ay3g~a zYT>dVQ-n?JQaS2S-EVj~42UvJ9^q82MGCzMo7yTBgi~(9D68Nc6PsBJ;DFnE8+VvU zH4RQGDrIg+D6J_bzH5=OZ|PMRqa z1;;D7O-C4>W#vuH>74Sg^*cIrK-3q=N!2nTmZ_z3)FB*K%3(m1 zp&Y`gTEeMX>v0&|6fA&KZhEKO0Q?b9jvw=@?x*PC;gp-E0k^S++#U{c?%{yj!>CtT zBg`SO8B+S~;gp-+DL3Jio4HH538&nIQ*Od3H@lZ|TkpfH5l*=Yqoj9Pdar!#zxjrp zW=?hg%{K*Ztn+toJ*vMz(6Oyn({sFawsf=UjRD_S=dW+Y%Q7}jLzX(DyNmM)=v?js zJh%Z@p$O|Ubmf$Hzvw}~(n-zR8#S%PfU|dtO)E%bguJeGu`gCOxFuh@1HELwR zw2lkB$^+vr(d*-O^rLcMf1y@QyOs@>6 z2LdGu2Mc3cCEneXl#XQXFhxh`Zb7P!7#sqow+dxS3IJO_hM`0?s@AMh1Km-tln_7} z0;XgsVK*n(TdG%$LcMek^b#<=p)4O4rZ!Y`M_Ibd3M;uAUxZepC6^Aebso?Z#=%Q zfvBqJsfa*81;~w!=7>zPPqvicbG`k5lkAm*`%Rb229n$B^+0J z6E?L=<)}kAu09~F!sL-JRm&J@ZV8*(GKUGUOu5adNMrJBHdUFLhdbQ!aEE&yhJujW zOc%f@H@#DC!YMb0q?2=#)1H)4ZhEKOgpIq*FQo{6s#b^U9O>n75L2~;Q?-Om?NT}F z(8z%sj=p(0u&M^m8wF}T9H{lMsV&?dPPxq}ltvik0yyQScghXF@K~ZB;gp+j%1t=s z2C#seaLP?M${I!4nPdSRaC`57+r!2kPF^()e(8mqOL+&!!+zG zVN+WsFN3p8xlKVB++r+%Q*Ho$hAd5_Iy7eiNUjsffN7=(LvyH>FvGC2U*U#B+xpkO)=kxt4`tOE z&7pg5xBxb_h1+`v+#WXWu&-(woW|g0ePuuk8>%&<1#qgC-ln$dtK~clP8cy3z$rJq zQ*Ho$PAyHOI#lOKQ`kgi~(9 zDYpp>`)vvqz$rJqQ*Ho$hLp)`e*R5R2&dc(9&j6L$n9avc{q91G&l>u&CUWoV4&8+ zs8@L=%t5pnvQ&;bgvXK(X#Fd>^>C_|a#FP%K2uu-C!BHhZBF&GpEcAw-pHoZwst(mTlA}RB229lwM!7&vnkfm3r|99~l$)jjx3PxY9u9Ku;egx2s8?Ad%ptKE zQu^)Tl$+iuH{q0-j)h}B4 zg6LC%;=5wko{@@t)ahN=m#-J#`xH~z%Gb{(gR|qo@%uLRxBFLNrJt07UHwU^@1x=R z{!s}8OME3t1G=w7osn-u{eyM+rp;rF_P+jAn7oe*?dwJvn{`-9BG#YN%gg$6beE4r zHOBH;C`7o#_Kz{_K&8qDod6BAJO~WrLrZ3GF))~UgIQo8A11O0PXz|@!5}kuI}Kj) zBJl|yvyhMT$j2bY_yQJE$bR61I?kj|lh1Tb^z$U%3LhKMSY5+mZHR`5R6d>J!iBN; ztrTxnVtv@Fd)-zymVT(D{RZ6~`kj*7o9~ohi|>^DDkjM;{$@g_rb?O0_cZqRe&O=} zDIX*`(SC1E@lX7zUhmLn?mkW$?zxYSJR<*&CesP6Q=NcMNSl13XZRfdp5r;TJ}a%O z(z^TdU~pAFjfe^n`G|&2>NCyJvL_9QreD?2O3?~Cj&x~)0h_=810Hgmn>D_;aV)*KGJ&9CWY*W1IXw;5H+30PTR^9cJr52GdYHq!-g;B9_YzX^Ca^){o5 z+s82Pa*$xuZyYzn@bJ*w+&FGiK&C#Mqzq_@>W@08p8{=FSH&+hK( z(Q^Hra@-t0&id0**AE>(D;2o`lP_-Ko1*f;&GuWOZ*1@D9vVokFaO_=|I6p5di%HZ zufk0~9|}jk*Mn<)ulEJ}e(<_D;)9|4Cxi{&xb1^8wo2=6|L(yzLUk5DPb#_pgRN{} z`aUi{mz%o=mYX}kvH^UFu0KuJfohd|JXj30lpWX&v^)r`<%Z9!Wyh>92G(--XV$V~ z*0aD`ZUxO+cFg*zz*_DL&02QM`t7v-obM%W63rZYW-qslmvGD2b?A2-WY0PD>F~u4 zLtpO;9SIw}8*c8Ds_V%+O6SQ2=ZS_$q}*aUH`#MOAr)^py^pH)ZiT8dOPz?(x`2`4IHu&x049Z1AsH1$YnwB@jSV~#fPk9 zgKri+NAM*956iCJ=N@S*o2<}8?r-&Q#tpAlqyf_PhF2@nfGlc*R^&fU*E=(q3ssL%>26CF&RC1g22p;|7Ej8uZY|Rimnh zj7xWvm0kh?A^nW7NVI@eC;}Er?hGwWzFzJO=V4XYJ}S<`8a#s84ON@NX>gZ>$eSHY zgA-1J6AqM{W+tafi-&{Y9!`UsQ5c+X8k}$%oE;0&Vt&mzwwqKB2f;0Dm=+HQ$~~M0 zr-DE^y$Pqm38%qLL6vg?MzH|y1UI$eK{O9{l+VLya5F8^tf@U$rom~N24}xJIrqT{ zr@;va!F`M%xQBz_9!`UsQI!?~RxW_k;OtncoG>WD;Dm$V7PdNwsMf`= zf|C`RtJy*i4+p_LoCY_eN;v^57r<$7$_#SOu-1z(IN>0;g$>i&l!Nzre4SN{HDqoK7*BOBHC+Qs8$)90y6L4l408na+gejij2kxQ$1sQW572aPRrv@GaZl>ebAMufoySV$g_pw z{z<-lnAXv94-(~}!vm;?05}K~CF&PEh{4nz2`zdRmq0*BzzD0SOFe3|Q=t<0wK;*Q zDN#g^hDr&64k4SEOCv0gZUjtwQKEi9PGEZD;k2Ednz-(>jHQ}a-*P7P0B44dN90X7 z4Nh2xIr6q454j1a!3hV!$!fi*f_pd!?%_1J8HK?Kr@;xQ!P&7O=jM0g;Zzp`aEzHR zmO;)vjCvU{rqkw0b*D5BcY@EuX>cqOzhtuFRN`qUBP&r`> z{`{^i=D^+c^><5WkIQNf-H~70v0{I4?R`>_^%U>NW_0ZX*lZ53Jr8*2{zJX5mfxJm z_}%)Uq161Ej@x8^|E~U31X$m{r+*b8bb$wg=mHN!sTX*N7uILKRkrxg;l z(;^9hvD5=`u|-&HRhwJ-u_sGE`pm^klp~8i;3OOP$ObO&9V&|JEyB25e&XV?!Np~Ri;IS+ zv$7<`#gRQ1MD|>SB1_kSgu1wR#mCd``H*#g;X+-1;8XXyeXk2^TPz6Hws>8gwJmq* z+LqJJ+7@iFw&feKtacX*YxOjvU1mszn#wZreL2sJdXI1zdzarN>sxLv>s#Pv>s!!6 zTi*g@&E83dIKHFGB9{$+_Q>aXVN*Rq2rs}gd28{QoG)7sv!KOf&_ivS98!A(vQbyxInKy2g(b$xg z(eTEA(G41-WH;oUoXxXFU;E0_1F9tgls5vDqYEHN$Uz|BAYdHjO<^uaDc+i@i`UfhBs?DAY@L z(;IfzyTjDFOLvr|yBrvsU?E_7WkkG3phV&3&2cj~a97UT*vj3Ml#XQXFhxhmZW=1R z28V#@twNcS0>IXfVJK0JnxR5(O(NZ)r&2-yWeAv(rG(v_U~j2jH4638JQ?bKc#Ql#XQXFhxhaJL;8Q9bLT!5(wj}QI%iDrMva25E59T*T?PZrF)@dzUFYsP4ASOsZY5Hr`&{7Zo(-yha}}DoN^OR zxe24Ja?XU<;4XjzZtop%dpPAbqmY|0$^~%BP4ASOLlWeieuPtQ!YMal+%f)-=CJ_%DNFAl^#yjLJ03Vn8>Aa)S;0Bef1k62DJVawd{C0r#u{} z^)TwyuzNV=WXkIY9QI(8Je+dV+qlC- zYNTM&z|9ir`A`ql!ev3G2%Flaa?~LlSIS{PlndmfY8fNVEn!nz<}d-4DYqFFX&epk zF-KLV=HU+aJlx@)hoKcDYwN4gA-1< z38&nIQ*IqaJ^{8j=f>B*@<_SqopKXSxdDXp*);nrS(Q|Jr`&`CZi?a@4aVEULBBm5 zaC_Le!^x|r&9S6v!efa6nUGX1;Z!Z*R4rjsTk=e9mMOOhtR}DbMtuRCas%))WN9MR zp-Chp;~H$;Z{LTcnId1RmN3eyGX!)Lqhra#0k?;ZJM62P29pME)>j`8^-wL-5{xUo z38!iao7$3R@11hPZ|S&dq~K!H9ar)(V5*jIs+Mr7mK15G2&dd8FdPQLC<{zEHk*8= zB+v73hkG7I%Yxu3H^;csZy%g+%1t=sCT!egx+_I+zo8D*Inv9qde~ROsanD)7kHc6 zY8U`47$w5Sy|l0D(DW5Mfr`+^TxdHe&wKS3HP+j{>VLNG3wd70H z5=L3|l@xZiS^x*!-aFv-aLUa=4CXVv38&nIQ*Od3w+RgUZ3-5^DL1`SZUBCUl*wy; z)%`O852GGRWAK36SVL|PTh7DDtERzO0B&{`@Bst09!9p<@lJ|DmdYkn{diaIOV4Pl-s%&=9zHHO&DeIW)t{0o2ofA4|n=K4|lld zVYGzY9Fk7Iy*J^Mo3L@0`K2+I+|0lY(|w*VRSTB|nIddzm&#Fx>VCt^VL+5&@(8DD zEmG)B*wj|3Ae?d&Mp*^tnApr(00-RO+qlC-s%dZgi~(9C@XJjPUn<|t>0lH z)ijtiaI;C{1ERh_PO6p(u}m$MqYmM?QVs*64CN3`)e=tCT93ovreFb_a??BI2H=l? za{QQIbw5Q952xHT4Y-Xp52xJpPPqxE+{|6dO*rKy zoN^ORx!Jvx+j<{njd03M7$v>S(tG7=|IHBWG;^x^&wwa!W1YWy>#<b$k>kD+ARpSXc;CtPJ#Vb;P2$Iy(Puabaq4 zITOH9RveO$z!JS`)a0S7m+q!F?5=m$*;cwsca%jI;Q$pqFWm#Z1Wa!z%g2SO4Hex{mhNgF{M+pI*zl9 zCv>-*gd^hJVQM2ncc`xP5Q?bKc#Ql#XQXFhxhaJL;8Q9bNu1 z-8~2SxN218mvQNCy()wRmgx0yyL#y!=!IE0AG}^Q3iZ<6^oD8l?l849(j8@$MgoCe zrYV>Y1WZZE9D#s?07{a}!-t;4!auZIu2^)8LC8pWFvCbdUJ;*K7#u*vp*29+Da{eOB;S0vq!znktQ*Oe> z9geFKHE&+kp?TE_H@tbZ{`ot>YKl{>XkXDHvA|2jlADfZM~Uhuj?Yl$&tMP1v}@M5>Np(!k9o4b?JWsunKcxYC=j zsa+~Z9l~+-0bvy;k9?_G#z=EZ*wmIeOn_y|ZAL{JlV`K3%G5mE;hu*(-19ILgxqGj z08Y8-opKXSxj7`AoSU5Xq?B^gJLM*9++}_#MetL#I#lOKFNcGeswJGNC2VS!%29_# z4%~3`&C7vRHE`Z2Q0w79t%pr*;r4LKZAPIq!YCKODL1`SZuo`A68#9L+=NqZ!YMa^ z1>A&FZo(-yVU$(YD9X+x3*dm;dk5SeHtulps%h{`FWhYMG9U*dn4KO@)zTZLVP6TG z+A?_=oMp;w3c}zPV*#9U1Mo9sX(H93ISW8?ok#{uGesDhL$!oaRxp8dPI)-s_ONk> zeO1$7(!kC7>I0%4s^zc;<4SMBsanFOwoDrDopQr(>9}g7rmyM{jw|^XFjY$!Wta-W zsajH`nIfEWo4}BpFvM<6hcVb!hquHyqm5zxK6ms+N2xtIlW+ z-Fw3Yu&FKF-aFv-uyKcdRny=!1~=;~16tTntr;zVQ?>LqwN+m&=V5Tdh_L`px#^v9 z1MqWdX(H93I!BtqcG9G3$(O1njI!!0DeP>u01mjlcfjr8l$(PXoTunbIOQgsauZIu zO<>q>Q?LL|x#^v91MoAXOkVTzZ-PQNFg7v`C8%1sz$ z@n#eFIGd_DH4k_CJr8%d=V7#j+#Hflzr8o%l$)?|m-(eJmfXz14by#|FI5Yd1(_mj zYM07Uhw6UA%V9v2Ve$y4YAsUeP1w{{sUVzk6GmAD=a|^cS^x*!-rKmtM5<|U8iSjS zs}Go}HKR~1VU!DCQ(F>gew<~YH~jpZTH052sLqib4e~Kys+KUy1#;3%ktjG`$!$8q z;Dl3d!YC_mYEI{rhppdXBGojQG;p&?;{&3;Ku)Tb39(Eqm7@;fxKa)Sq73B_PSp}l z)mo3k;HF>!oO07U!xLg~i+uN?e^tIpF$8#i*1!5SrGBvP_q^4` zjj_gB8Bc#^t2bUbkiF}#pXsJ|Kt6njW6=AEM7>KnzCq%ub^q$)Qbq8`+9tXqCZcQ* z8DCtvc(|z^tQ;I!onAQF8=rpVl~+FUj?-fKk=GAa_ebz~Wo;f>BlM9g-!qcV>$f-X zEu6bm3y23w4;fH4&=S-TH=xABhpPNwwe4uz#We24PTY%svbgVMujP|AkR}^w zA|=M{p>98BE!C`LQL(dJJ#y9#lT3^0cgYw|{{tzQryrI0_f&ZT39|-CvkhjC0%wO; zRu7I4S8AqjMjJQgGx^?5Jq?(6=XNkV*cp!3Cc|##)#6|>Y)8QNLG&Uozbe~TLHG3I zGEvsY`U#G)et~1GAK=gw4yF@9hX)&H?)kNsFRaWC-#wVFOUn_q&(3`6d%y7JmDvhp zfaF0Ge_D27|A6bI>x=XYLqv@$=)q`BcSP^%%fq|xU6CO2sgy%&JL7w0*7hgE$>?Bb zWvm|rnT#h*jXv!7!K55k&1^#-<@C^-N*_nNr;bkl7a6jnII6FoU)OSD+a2~wImNNR zdu0EC)&9!#4yg~g8LlYTZ2C!wF`mBE&Va)!n=-oN&CzUq?fAiwwb8lHukHyaj^#UK zds4WEgNulL9&-X`j&XC=7|+J;xESiO|7x)x=%B&Ac*;zrrySro;f!Z8f6$_;*rrt- z?(+vLvz_5^G}|0XW+$Ung_z-kHe163Nhz`*XONxI>e_fR;uoXjqbWQIoRQB=>Dd4+ zJMvvD`8Z0ytqk~AEojj{$+<^9C&S*{)0Z|qh3?^VGz`j*R=s~kf~ci@j|(-i)EX`I zdqlLIvtNi%OF0FD(N@wtWqXxu>N}4LZ zOF=uGQo@gWkOsuPPE*CbMN`Fn1YLCM&3N9p|1h`b`n!9__Kue^{hXx()gnhP-~=@t ztm>GmT`LFQLYflPbg-)FU{%whE-L2@{oVUl`@2qtX65si6;z81%?d%y3agqGwQFT) zRtRcVSk`(uZ)TTd(qXm2a7<;$j zyo9}9#NK!CB-EdNr-;6|_m=+P+`4?gN_Ot;5BBpB2zkr>Q9J zYR9DI*Txz*OkKVKY#K0u`Q+a;U>fszn}KyQ%W%AAZa6+PKOFzE9u71_!$Ctg9BgpI zp`JP%RiAJ^XvTw1n2ybOu)&Q78{Bwk*kC+F|f70DwlTvqoOLzZyyZhU^`!CvEL>lV&s`2mW?ytA# z@9OSvw7Xx_-7jEwb_Vy0Qpin&3LyW-w0Zjda(pYMwZ2N4s-ni=m>lvnK&?N8$f;^< z3|^4401Z&|1|nf|U)OCy-25u52Pi20U5|C~PAp5N27RA;|K z^61ey`=KE^+-T?yH#WG#O+9(I<;NSn-0{W+cf7H|9d8;}k2jo~hP_iVb@VxaIi3Em zGQ#WQF(%f93+Us8@$BLZ_p8I+m&&eu5q2V(!KFhogNp?AL1ABM$$gaVVo z;@rkK^#D!(w9bL-2s!Ab5y%2sAawRg8}01o@Hs zjON|epOb#~ucDMB{CV9$iL8E??xDoq$8`_o>_8)~kI$_B?i+7^{rJNllSUl9*Uyii zl1EnrTWO+UiwM}lcV%{EZz4M0i(d6E$b?58X9Lb^WW+(}mOwgDksi*`2O8~z>5pPY z4yK=#P4mPI8a^<~Ao3BK4f2A5XAPwKZ;He8RtYYb2DPHi!QK-bc@DYs88hSnGa6T9 zPC;qK+>;F^-GL18;L>oW4_q_c)3rMsBHy4R;~R89o)JBNRk}S?hv`p2njY5xPD-bT z`d(uC(*X291#m^iayUhR>3;=4kGyZ{^JT~I(ujgCPXCR7qJOR(*RkpUk$|{zP5&qX zan+jsTYxx!{l1Ka%&Eyx=ef*?7q?|KfAcWHz@>857h3Gj{T4?mS#_3 zucy~qy#!onfy1;uvbw4lWqBF3HoLepoanpQ-UHtttUoS7zGk|AFd6URr4p~~O)L4E*liD1sZ^pmjd<5H6~+6~?-8$r>zkq(GjwA( zUYVYinRR%yy26R8*XQR)@w3O~AqTM&Ub) zJoBuvKGu`W20Qe6JU$Q@>*2KWlwNcW55}vUReIyl#<`bqAXnnx{M3yDV60~~9|yCS z_Zm2V$eB^kAM%c6I9ng%r5G}#_e~cDGr66RCcJLyUFc)*epnhYB-bxU^XTGOPAEw2 z1!;!W1!=xGlGj)VdR-j#{-${1Kf&6ddWLAOlbg-af_}l|LSIp-3Xz>kWM2rm57UvJ za#geCrLTvdn2H!P)&0g5XQh$ee5tGS%)HeD=cQOPPFbpYXRmjq~MVhB%TK+}0h%sp$Io&7rb4{1=3OEbn3= zLA$3rvM*xVj*S{!?_3hE)n+kGP&c-<7Y=LM`wH|AN0Z!0Oh-7Yd|#hEkdu+5t&hv` z&tJfy`oT9}#OCyyqZ%wcDqefBTEMemmMR6+xJV{;YvMY4SnTOCh%2i}vrrAhzvPkqPtot>Q?ea#d9 z*@2AasaxND^TDZ8?|Aq4>C?aVuRnEkwy|<*_uVT`^B!iYr zJO0h{Z^VyQczX3sa({s5SKovmfMy5l@*9@?rnWkklh0uxJ^Jpa*H>1s*;rl0CNf)D zs5IPcP~2bdv7eG$etr27+x8B7}WQ0`B65j+g{D91#&Un7AVlcO4aR!yOTK)N$Vz z5D^g+711anGH#=eIxeH5;~3xPId!_z0i18X_wPGV?yaZl)H$c>)~)5#soQQgWQF65 z$by^|xpHzDpWKb*#N(dB$DOi)@1glQPfIfBCT1xcxJ%^JTNB+R%+Yl+)Hyn?3XD#b zaIva{vQF7SI2Sa7+Jk7uotBi0*xr)T5mFN~sJRHYY|!VaIc-U)V*teHU*_5+>9TdK zUo4_y31bl*s~C+)+wZBn&UBpC8thzWoCaBIb3xUP!OkXkm#B5-f?8)TsCA}Uw9Zn` zigRp}%V1}@E>WMA3#weD>-4N#qRMqamFt2kSF?1I%SpY-Ww5hcm#A`GP~|FJC%GVtL_t=341QyZD+zp z1dXU25xekD) zaVt!5t2Ay*QEr9h#;wx0k%HVvq1*~FTIt#kW1Ywlj3Vrh6tw#a+kH^=F<0Y`pqG2U z($uW!PSjtxcis_Hxb88XmGzm<%DU{7HB*zSZ8a(Eq)An#CZyg$O{y|AVb-)J z%(|5(g|H5)1%Dt$9vbco+XHT!i`M#q1#VbnBF9pfc!k?d8`#~ARV3m7MIw$$j>yfXh61=$idN?i7B>)n4ah+U8S)l5!sR`#kLUR%0PdxJW#&IT3IBhPqZTBdV1I9 z1|pV^ma)9Mw+YXjbIColx2By)8zo5jc9E2KYodtdi^f>qT@xc));wc*cO5O)#HeBY zv*rvsS`SF)tO(k;5zCj3vAny+NuGQ@jOE=mRmSo`F_w2XE)@6!W<#tqd2vYwFb^@q z8&H-ambtMWFx)9IJ0sCm33X5La<4|*ryR|u0z?+H1#v_Iq=1e@a!grS5`#i=c4xg( z%uKN~H&Z*st|Qs;GsQDo|2pQMP7R_p0N zg?y$859&mFaA(?v&a_iH(H`2F_V5f^*IGL3<$4>GgxaB6sSpt5v(BjyAVm?=N9ptKB2f5R)gWM?y%nHd+hHNvHVZKadj69``k*Ae0^0YEWo>Ioh)5;imS{Xi$ z3}whBQyJ1_Dr4j+WsE$njFG36G4hl$MxIv2$el9I$pw#$wg~hS&acq96{z<`6l0F= zY~hmi)SWeW+*5K&OE+Cm8*VK9NS;~-`%CFZ-#kipqSE=Mncm< zX6kIno0+ZsPe6L2ov5Fr(v0Zh?A3$RDlm^0z2(1^YuvJ7E^V3H@cGTsG@D^(M!hX~gpG#Qw%n}*QwVuE9 zo~27`IxhO+xs`GWx-9h{i29uiK?r2!q_xXdwD4*RYiAMGP9e$tsIYbyE?OhBl2ftl zJ2bXI{ogb#qFX_E1;K31nytLDm9G*-U~T0-CakRpaa%JH3d06N)0V21q~3`z%&n=o zr8O=tqGrn4WG@nAhmvPQLAW2XLxX74MwW)XbX04tj&aFT@b)Cn19iyhaWZf!2YW~4 z;7#>+IcR;d99qcC9E?B?-nN$`5iQ6mE!*>on$q_kJAN5L1vOf5kMGSc6TGOGqiU)} zD>Ll8JJQCmvTf25e0#RkdAS{CF3scRf>hO<4Yd(wVUkoik@vePG1cA6OFoN7pxbU< z%!N;7#&|)#m!}gA+&HuFVh+U=B{^>|@Q}v&1#X;BfJ9MDk&<}%sv%@aofcT7I^B@{ z_Y)IdoS7u4`vgHeJ2m?yx_J3ck9;R9-zk^hF{AMc96^C2C~yP?83bJ%LHee}>!Nh- z>8H286J0&e5qplC=eT(p+!Br;;Rq6rAdx}P#igWgT2);-2s(D!6NLpuURTGjt78;b zqz$N`7-?F;T{W{8R}z=j%`0^I3RC$=>C(C*P4g9|@=*m%E8Q~tliJcl<~P=QMP8Bi z8(y(j>;m1qZZ6Q>>#hM;JK91|XCo;=rV^(hjlzi{9sO!zS!wRNm&R(7_WM)5&TKb2<#ndZ z>$n$2@2wp80MphFZ)48X-E}L%+=xzoBf69rQ()4@+=fMC)}}2%}xa&oYS^s4_?j%RajYR=c@`j zsZLF$(hCbCFS8;NLDcy6g;w?^Fc6?3;nc$d58=x$8bSHj(F?x>@P3Qg)Zq8li% z+vYYe69}Y(B&+kXoR_!T+}OZQjT;!Mtd1CE$!8F$YPu05T;@P}oJ_FcaU4oyW3R8{ z&0sU+j;Y5M%7E^NIIDQt?;#N?h+c^m}?AHVC^&LIWoEIWr_ANk*$<$rUQ zSJ6=aH)p{_hOf4cssEd^u0A4=6g)$$Q`lE(Ve;qrn-IK~FAn(C$55mQlhd;~fS`@h$*sk4rzq1s|*!as?FZEI~R z_9=9$4XXV#s2}>jEx{$J63~v@3jJx5Vg0F7POY;YJClDyV{l5nZvRI!qeJR0hQns6 z9&~EJIYw5dvr#*nv{q%NL!8@E(CCnx^U-xI>}r#`^!AIuXAkENc_<=Uy5_q*Pt4Kl z?>yxKXBMFD>Hs8eIiyon7okZS+q*rHJ+e0}P#WcB&s0+vKx=J-x^yL?Ajui|b&8jl zvn@$3Nb*KLncnNgcOIBV*yuh!-etCRjqSYh#>QCt_HV$Kd6t zyZpK&kGqo&7h_I^?vmRbNegq!L&I7eFNT%3^6>mvX4C&GQQ#GXhkD`MY_!yZ9IV~F zSR$(;*DHt&4SNN-o`?86wBtN4uefFm&i{HjKsjDcaZQv62Nf|f&MvNbxBa)@oX6Op z!j8(-kSbr1=g-!8bU4|c#Rx?-?RSV{IHjr75l2m95DQ({a#(8P9}{zH2$+I9vrr`5 zpe@6T=su2?yyabbhgbW19d*h~KDi-{n0IE$1E}5++HN>Q_9x$D>w#w)y(2-E@}+h@ zTWNpjoU&vMYAu(Ggv^zg`fq7co~|F*U~JXak>rlF@`c$p31=p4T}7u#o>0wzZdvBs zEjefY8ZB~UTKA|^UQdO$E+hA4ZdpmEG}Va(a{f8nOVM&{KTqBExS3)@P!HPGP^2wL zJIb0Cx!siQSDoge$Zhguh|EpP+@=!KGPj%Jis@F;Tj{CA+&U#gW?ovR)cs!7Za2l1 zbGv!8oLi@4$V>zyIEHOco^@Tmmm9Q(xMIR5bBeS324ya4jQz1A*eMc7@)#L*^z;B( z)VUE2jRrzhmN$b_Ypt5ku?S1?)dw(iNB^{dI*Q~dv8r1eXjG1k&YWK550{6h(K(bp zC{QLb$j`A?eb}7P!4W5FSW2+k-_`t{AyFekQ&Qhea0g@93(_Bq%|Xrm@C62-iy4zx zc*?`ESQ(}70!KQy2k@3tm|HO@oW}t@mQ4MM1n%rrIAW4^1mSVXHJe53L-yqNbipPe`UeUw|HbT96^wQTB)Y z!G@3{25k-MZ$6%{Wp;T3v^TkW3^ce0&^RU9St2^CH7ILCfFmkO7--P4YrOhaz3NtOM_YFa`?X1yyCaP}tL=0z|C6iJD&4z*m5sc>h1X@gqSlp% z>)2|Oe}$6Cd#UlZ(R6z$Kf@|1VvJcr`ji+mw3#5^l~mp0P&_IlkZSx{a7xX>bcu7x zpww(d7ju(@L2j6Z3|k_Q8;Y#09uY8U)l&YOv&|! zs(bmvJ0-x-#-2!H1ANmPE8Omweo;dtFAy9Q$gZFUt+mUOAOFLd)zl^24v3&#inkpABAM%n<#kyd}y8OSOb(F8=w@X|4tGObK4|w{D)OkM&o&yM$rOahaMWz z8B-Wm->=^7|M+A0SRrXL= zCEL`z+dsZ~t2%O}qZTqG;tF0-oN}SSkF0VRtI-insdw%&X<`;ciM2!^*1IDjF*QpQ z6DYCj3u3)HqABK{Fjt$DwlkWBw^IZ>=PcQi+Tt(W04c=$9}Zx>3*lx z8aF|=5$RevjosTMY2Wf!h{jms+A^4@raH8l_|3_j%3wXls#Njmimpg+T4M9Kn>9P7 zZ1TJdZ{H}dz{lA7Xk4cr; z-;gRMcPXMBRiGiwwY@l3o9?8k_AEJ>by4Ij^;%lZt(}c`9?v(18Q4YJ8TR5;=nFrMK0$`Bi@0+G-ZIA~jM zmbtV74{{1sjd09DRYg>;4x0`v7B^;?-cT_B(b!UPkkKqmLq*&|xRJhrABk}j2n`{R zRAW6;5kWK?6u-|yPHENrURHMc<28J@dSQl!`HPibGgU>LmR9|U!x&Yj!)`Q``ofb^ zxzD$s%m(ztE<{U~XX!;7<)?X#*D4$3fW#d@=t? zkB^RXjuVrc(c*`IiF;G9Gsp(e=#RL2y=(3rqTSp!OXpSpi8q^@<7KWUWXbwXZ3D6# zR{njVXKlLtnE$gH>^*ib>DF50X1u#pKH$fKN&EYb8gSzjtS6a$47PvSEIKYaMF`=j zBy*?w=Jb3Z#~q&XTY?)D0y?q5cc7Gv6PJz|Mfs|LPFm|+mrXftIju6KxU#90&V1tZyd(+i^5bs{R=gF1QZISIvr zu+Uhb7FA56OHQFws*6Pi`uFl3;h!T?J|q18a!0EWK5431=Q3)N%Y+1Ku39YdxR`jCqhP;w$!yAdScy!K9$9DSAIi`(%qGH$~0FF99eR?U7{;ZBy21d zTAezw6&aab9dnhQk+~z%v~qR0dndj0q_b(IVf)r$s;%aQ^z;wf<#@d2VF&6JIIgKi z=N2MPysb(cL9^3S?b9wnh+}zV%ESfj%l8aEq3u23jpvu6vczTAE@` zOD?E7UN8HSVo|2j`qhjVz&4m%g==yJF6GdH&kbkL+;dCLqg;Lm564r~w zbW=4eN$0$+ns?j>zVZKE-k=--xpnMU)B7l{n{bmWcm-+`&`WXw42}5A9meSNXYOc) zX-vKWv7wGK<%YBPc{`lx)z8}ntJg(ztT`95+}c=kk~5N9gAJwL ze|*R;{gJzHBR*M&6OpACwwhpVs zP>0Pu)M0Yhs?e&bP15s+^-~*!h338Z%XQAW2LnIPBXvZwQCXxuyH3Z1g~N^1hV<}) z+OshR)?wIi?ITNyk*qc*7$4jd3c`pp&3!Tehb-9+Xve5TdF%rqQJjP2cM&j3@_oW-IzzE;8dCDKpQu9}v&++V!0L4Bs?IPKHD+NaNZ%Jf zJ^%y9TAe`EV)G}xx~0(9n_vB!kQXjZZg2Z1W{iLv8@ zPlFN<%x|6*lv&LqvSe1vJVcyfueTB(YA?8g{as4dl++%MOdLAa5p;@68V9!EP`#6C zy}0JII7o)O?X2cU(~<)zjh=a&PfO&Fyo5>l2v_CvRu%Q)m7!uU-dN!)N_eCx zs5l%}f3Ki^rkCaA*U!}VFA&4$H}z|ikyv(eBMOs96!^XT-evW@CpLl@fh5L>sF#@R zCA`qks0UYRFOdjUAj%654I+%qJkP7=cApm?e1n&dZ97$>W+r#>;@C`!BPu?!J0ZSn ziCsNca<`?L$SF+Z7Gf7LGA+@i;z-`QR_q5Yx|kMS(^~ACDD(=w=+FW$ihaVuMAYk= z$o0A;@}Nzjmji7I5uc4P#3bKgak9Ox5Z=}6>O}_M<8?u}i`NC=Yf%@u__U~(S8tzN z)+4{z0)@XcSi~~6`Si@7>!Yw#ke%7z1+$WGfxk1HyB;}~rH*jgUbG{{>-jlobeBKJ zUm(eeoXqQTBg_wcCf`OX8YCu@2$WZ09*|X(+{7@~s^-9TWA{P+f3!>TOnUd)%EZ-n7!0}Gf;%#AiEd0=4wqu* zmFy0XB-DpkyzZq>gocl`t@;wLgtYQIJI?A{yWH#EXwE6{BJSqCArq-D)2{#}dg{oX z=#}l2)MG!%EAe`A_JHtBEwqQ%Q|;H9n(+}1M-hfca}TdqilS%g zQ$)JL;{~szy$QuN)3CYj72v(V`&(R7MPzSqam_z}{p~jt4BG)y@FE4h>_)tDS-j;y z3G@YSg{hU6t>W-HM)3Y@Cm~N=5RjIG?u--oggX(C)quV)=EaB40zqs_qBlrz>jRXl z?1A$-RJ@keX)AHNZ*^LDOi1ZZ?p#ycwb6ME=v-+(rVCk)1s$7>e|B08R|M6lA)Km8 zorhiuJqDueadfw0oDX+2EUD`-|#hqoQC?Q8v00j=fFn%W6NMiP|ugHiVsFex#5c zu$x+SbM`<7%&4iLyb#Sm+rb^Uhu991AERYsW;!Vw)31)1DT;qKrU!jM<{O;bd^u9H z+-$ipS}`r|3nkA%vU_~WA-DPc4w_WDJ_V;nQLqwq>auCH7Hv==j^a0$L}gY*R`Wr) znUGKsKDc5!R9}wIpalw>pGLCI%TNt}bz^CeK5?=e%8~p4G10=Diryi-hGEt2M7-4g zR1 zP#y_am3G!4?u&FNgwI}57j+*<>GBF+Pdt|(*DqQgSkyWS{QHI5{QHF?zbpx0fZYS- z2o&PmzBsGOhgzNA{1WQ2`Au9}(D_Br2qHDuk($Wqlo|*Q$#3q=@m|p8p*RAuSsKnYxplU=UST!URmq15Ti=&07 z+g)5-tvdD}p(b=SXf=XKLVaY*^%@z6GUjlh4kt!NXS9i0*8g4xt78 zY=LPvw+moi|Km#<%^ zd;?rQOY4*`kxWVK?{fC1NnXo5D`%p#@)BAbcX zVSCx=qm)^~bhZH<*__x;(ki`juLA#)UM238-j1mcM$ji}BWiUojMFg3aQiA>zZi?% z?GhNZf+^QKKT_!>#yEVynzz)Jr1}jk!_L2)bRqL4m9aUoerCLct1ORw9%gN^RWWv`03K(_t@|ih+F(2)+Sfr*th=*!7LD zdjzxL7KL94;_*;t8xIKmn)rvwmwwa4-5LS;Ne;WEzXAiSpWfita}ILpylJ|A7(|lk zPj~2tK_r>}Hpdi4&zsWgw>u=6{6@(3$g9 zyjA*#CeS*}uh#B>o{vrTMxNm8YxtVdDN1oYT(Yk19FvuiTH5yT`}lTcBYw>Y+YpbD z=SbbXg6ki_DE!vbVG|6DE}igw)-C|fyFv8{$qLxkush-fX3vKlEOM{FEhppGmx8l# z+$ZR+x~d0bYz`CzXX8%|dbyPQnTC%~w+;Wgsy(7>3>;Os0FeGQ@MxFu+#JM84_WAr`1nTUa~KT}9x z_GJ{s*ounGDCD*@{eJZ}EZi=B!db;(!)1l&$0rz2q!%^LxkUPxf(G8wv5}+vsP5Rz z;Oy&C(qZ?(xo3O!Y3~E~<&$N1+X-`V^Zl4BOFF{G~Y!A#$9Q=0#~?x zDTsn0Zu^Bw_$zk-PaXlYV@@S72e~q);<=xIR}!|b99XAe{_;v-qwKIrC2}hIGn3J$ zC`s6f3P;z-3KnSl6|t;FTBZZD7eIO=Y}>33FYXnFli#ArB)`MO`P(Fl%5)4$xKRhD zNw#oQ%a^448W*oyy*6~OkUw#z{Z*nnCazs*PsRV;p;fMb)XjJv5aZ6?!48RIkDyQV z)Yk`<#P7I!#r0lKW6=vn{T^C%sye>6&_iipy8usQPGOu3>zWNaRgmR**?6^KZj9jdMi&@^X%gp>vTB#X7>NfEb%wfJ zFRx_^PVL}ZYAw%VXNULa?7ocma{o-f+vI>_NuZ?`Ekpyl!wKT?jKF<@-Emr+XL*nb z+1Q@oN{*ijyAJ)J3t>sY4B_hbj4m&4;_G5=Id=ce2-z|*i?oBgixrA-lBw%#qgfC$(cZqDA8^bpi*elR+ z0b@gr2H=ILqax$#Xeh^7vIZ|_3SK(i;Ecx`Qd?_a3fCVMNOpr(<={7bkFb|Ld}k)s zhOs%-;AJ;tGE;y38l30Dr;O^oT=L}2iO8Lj!HiaVV+1uJxih0YWsqlT>gzqANu(lYpiG211u<@%PC;X9wy(-6EesiGkmXsMk;$tkp?q9&u|bD>D) zkgE?#YqRxi(Bt#hfyzLjBCF5i`yP41oC_H>+^?X%Wf-e+I|QuHfZ7$IGkm$2s}hZ! zcjLrwTjw1)l{XJxn+}Ca0Q{lKKqym`%Yu%eFpwNgVfzLHgVY%^IT8?VG>6sWJeGot zlamAd$uUUA$03K~rDYJ5@Gr!tyb0XZc_1~SV@{4^q}^Tg(a2Y$`P?s*@VuoS$3tUb z+4`Z*=+9#QMf96dF8DD4eoi-}TX2rmk(okGw2leG{mBUs;e0L2DUmRBdZZ7A4V|~( z2oc5(Ixk^btoP(@pmS$`@Er9IhXk;6!PJk}BpLyxp%XQNSB=oDb1nkxOpNV`&cuvx z#D4-YI@8(A#JggYXAj6%N&e*SsbE8SDuEI$fx!k=J9D@=txtUSRCBrntynh0THW-< z*p}Q=>G#65vHTPVwVhVQ{7J?t&=1tQcNe{`{<&(%P;11vR8(5&VdbuFGISh)gDg&M zrDCu>z^;Lt7wj93HU6-kr%RkEWj`dO1!Ppvu;Hm3Th+>lo~a!FmD~(jseF4u$zl6t z$Vy3K!U!}jRg~OQIT#++({flsny5Xiqr6#yiM9S=-(wQ0d*C+PVN7k=6-hWOm#&da z4U%j|os~$-vb1!Cr4*#*+EzR3n0_X%9jjD`=UxM5?E06XgnQn`CN0uE-%REdX0%S# zv>2n+_rQd)4b(wW7rgT=Sb#>Oq2_M6GOG#WcyvIhd6_R2MwfmNs--eJ)Voj9L7Lio zjs=<~1EVkZ%V^9ou!rjfn){`>paY<~(E@2NMBn09Y9}DjJUEk#nr@#A-P0a zPJyqzkfTEM9P zA{5cXC3!GX*=luMZv-e`lEjb)&s_FcO&IT@eRe$PpxYZQ_D`wU`trj9C->(_oaYbR z&OlPL7nZNeU=VO@E&!X#q6`)R?FgF~j}8Le^PG#p7?VnPPH|FsDQ@Mi-`D|dW`8_q zJkzcS!*K^aS8MH@t3Z+p(+Ci1y^@)_&%K_vUjA0SlSf$21dmDJ(H(waW&X)U>J}DN z6fl%i#I#uOfT9+RvC&*jjAdDB9#VT0K>EAw@uC(~O%!KHl9-%F;RuUsB#DZN!s!Cn zU>4439D%AV2o@$$tmui%h&CIMr=dp(dcY9W9SL}8c^fh)h5bpCKRTpO3S;M(I6s9$ zNmNXN`r@qi}4FD<+!5sHVBS1=7Y+SNR}VT; zvY;-){bkn~sc@k*&Ps)I>(~Ze>oIO;6t0 z%hH^ur2=Papi|DGD*W(xaaB%5LFu??TMhgM^YH^K=hq?-F1>8U$%yenxv^jdoyZa6 zl@2;Fpk}8!(&c5R57Eur83&5h&cK&*-p#guE=u_-4WsE-#m-PxU6z=|s8OS`KG@ z+;Gs>Md|df%zY42zd54TxAA#%(U%1iL3}DEYvcuJMhJvP&O)F77HBww&Qyi?sX_g! z$1zZYmS>$SfO@>5ut_A?K~Yf5%Mffb#h+k*JCmazTt>YCh^v( zw5O4gD`=I58}n*$)X6NR6l89x!{x&Xjv-ylwNeu}F18o4VC#st`qIw_46bQ(b&5E0 zt1sOvm#y8)Esfl}2?6~%pybgkrt_Wz<4ouBY%d3!F=*&x%)^}KC#CUXme5#w-rvru zosx)y77l6pU6ptP{r2#Un~rY@!HB7!4n~gyBNiHPam|JFugd~LS5;hdB9J~F{rNU1 z@#-tDjDzTY;}F_=T(&wSG&VRN9v;%=w48OF`NKxL`AeVZl~r51$6wBHi`S!!b#;sfFA+RpE|n_uXk%+fJY29fegOI43)$r3GPw8{RGK@CkP+dy@FP9ty^7oz zKG~nd03XF9;RduIOcHLyjf*Kl-W8Xijxjp&H#dGb>jP8g*)Xo z56hETU0bd~%o#XJ0tzS*T^wnG4$*HM1oO zC|J8$i9pE`E#fU}4(!2NAjYy_jOJbAk&pAkg^aU-&U=xD`>3`2l{gBSkQ|BpvB=-^X-Msd|T&;1(!SncF<6qh@i) zn+&2T4Rl8r1mK_Ts1bpJ-HGT>(-MTKFXtqGLLb@yeOn%4PM5I><&R#9d6Ew&h?u46 zHbmrA#PHm1M<@}*o?p?8K;Ru6`1xiev={COhV}?mymBeRap#J?VHLc*vd%I0vYNm$ z+H^hSHQhySp^BInYPy?2e84LJdCo?t z*|HQo;DsNlm<8|Q%PWrx&1SS0i74SB7=l^V%phDR5e#FAZcX}X>2{~4(soZC3mWK>=CS+Iw(4f$uI;&4T!;mQYLkDxvD zNQ%4TSqPqyT-7ir`(dldB1VABIEcE$dY zrUwzo3pAaDXgr*UfRaUEJTSNU^o&-5q(_*YuX-rNW1VMvg>7a*1BB=`kd8bZEL3#H^iNIZ@<6Xn*BZ$Pqm=O z5YzG`gtG-P0WGMQEeMP3isZ@Ul=|wD<2Qds?UNWyTb@E594Lua3=9XF&L#V(){LVH zG@a+7Sgz}vp2pRSq7mZisI>>LUK!>)?)uO?Ib??GL)jNjW94aI$mZG67gnY^7)Gc1 zLPxR~LR>ozYWGo5;!vpR8KeZ!aGvaFIOsGNLqpfEv6s9hjjj(gH39gm3Y*_ThK!fS z4Gw{(SxAQBw6S10WO_n;P~YKz%aPGtyLU{rO)eno+34O-s5=lYkfvv$-oObNa;%RF z=*#P$jb1c}Bj`;xfxP7eT$-NKqINI|H7(N^QihtABg}ebJsyrb|NPqV%vnrUEzg5s zj&f~T!5|s=TV7yLy95_xt@>M5GFCeW7ppyli)FX#hC9w3Lh^%8WD1^HplK!xi3eMjctj(7fgE>~R-fCCfS z1Nc!}{qU~_{|?5#ic_%1gAVzsxF4kK0k9(*u6Il9#Us(Su!2y6K+|zZHKJ=5k3iQ-f`yzVcJn9MK*FI#Asq9#Uf(ACxF=oENnO3gADV;CUT0-gfwMD; z=bfT?r&Qi4kax=Bo$B+>;_yzPyeq%q{@E-&K2QPmSWhK*SH8sk5Y`h1w80(iKd22o zt7$dreMo-H!I;CK9N$41NM45G1oetqA&9v_ujOTAW|0S?WMLiX(0eRIq+_Rj5Gz5G zw3D%F&@r?6Wys^KC$}OuqH~^*PSb3x{W5c`4J3P^P(i(B>8t~)Fba>RD{t^K!mXth z+LgP0{w67Yw4{!o7N(boR18c6CB)8-lAy5P=6>RwbW4WLDl#K zn)6T%isHq!TJ8)erlBRUz!Qs6ZpsF*Ri4ZAtv+nb}|O4RO6AlL++ zsqw99j4O8u&vT>yISeuaIwoUK zqkcqc^GsBf-xe<#&32U$=?}R{Lh=gm2pVwDpTkN{xM*M)wwPZuDy|*Pwn80 zVHmV1^Q9eeP6?NyT~24Yjura&TQjq;b>&r5X1f`6kROUwy=+>h?`CSIXQe{d%ldw0 zb2Pg$l)Y8m{vsoX_@dBOK6DkTRRF0OHIOP@whIKSbDJOT(5ZYfPGkfFe#E|<5&O2k z5KScD-izGkPmp9w#DKq}_O?>B{3$;UaC8YY{|a&pW3NN;BY#5s78cpn%jstDsX zWOqLYJ%aKl7s??U-kFzo^5dO6cu&!jSNXn$&9Dd0nu`fS(BGxy8Za#1x2U=2HgUzx zJ8m6!WVhyFh%3_07(4Qd0>)r+cTBq2MHKpz_k*Kjt8=QWQEtpzJim%fzRBmlqhIAY zP~}%a4h-?D1P4Y|>;WH1RPoj^J9OJRrfW(+OmWisMMLFt{Yk#6 z{v@Z=*hk`oz@OxMVI^?$!aY$2_G=}VVje+P1f_=;LUS#uV@X+Y^Uk=ticj`IH7qIX zW=Y*0Q|w`B%UP0q3l&ga#ZkPXmCt}b&5b%@GeQr=R)HRhtpfd%wgmK``V^VUr^#gA znGOrVY^Dc-O;12+xNilDf-QG3*nKWqDmG`hho6A{M?n9%XtzP^%rv#}DfqjTW8%yG zj>-TC@YlQBrGwE*%LS|X8JpLXOg9SZTS}A4b;~b9y@CD30##C%1GM;+Q9YYRV~keM=M{b zbk0-a{z6>^#C?US&AuWRsGnT^A5t6sV`}4nOr0{h{D5ubPMchQ{x*?upDCkmvHzLk z1K5Elmmj$e4|RXtZUVp0yWIp@-M5>-)9>3&c=-1c_>=JeS#N$Pd%HaE{=EeHJWVcN zx=l&bwH5ajyJx7!9=6N;@BcjyZj$`xBH%6hZx!N)e6 z>wiC&+X~jJnQ_LiXJ(x7>t%7( zWMAGZ&~!Imew1~>fqPBxxov7SuAVZqYEadXK|=qyoe?^+R(>$~xKT2WhhT$(Bv|0j)KQ#2pnzc{N~w|*hnx0u&5 z4Ii)JzZWqkSHrm)zer<#D`fmHrSk`dq`H{b-HK#vNGggb<&nzAntHryyL(^8Y~115>X5ATlfF8@>t%X-ALsK6xQo?Bn$q4X@E~E5i54gFCXkQ~jiWUgICv>#+WenOex};z7L5?#=6Z)#@mf z-nJtp?yY(M(1ocV6%9Zc-wrbTu}UAPd=5)6=9!+nPEg8~y-Dd;o|me`+A*x3yOgFi zNy>!gJylcp(d&{O-$8v2R}K|j&cOGt{yCbIr};ReMSBeLs;w7YPPd}Kz)5Lz3F@DS z?JZyGP>YigMh2i~@w_KVfVOy`4T^#iR`h7mN0pP15|LJr9D|LmMMzD;BvCemqQetn;^Oph!UDq!u4{f=L5R8@|ztGl^f((Mc;HI zx=Bt@^w|zXi=|ofJ+mXxT{2J8KIr98t0L+D&5qB@<8l>{NS}glc05a-lxsBY!~`jq z8Cn9$LuIYpr91;wM6bzxo*(&QNs+T;y*%ih1I_k=G4fEHfqc*l0MRN;^pK+C6>XA7 zHQ(O-L|#NM`M4M8n=ga%NV!>__6Ephl^b#g$Pp#);CfT#d$^uf`a$lb*2`jjg2QEp z!VhzY%h>K8=7!{o(VybFxb{=T>`?f*#(&-^9&g$Nm->~aezl!c^by0sCsYvnAa`=> zPO)!sJ!%lIXIFolj{hO{17fZ&;I+7d*YXj(ezemMX_~eDe@4u(LSDCY<@LcBuL}n8 zx_1??*Z1M|Zlzp0kP@FQ_!Xu5TCaED+E)tm-@)~TIIrFH8pbswBbDKk_rz!v3OpsreP7Q?guMK0#NCK1$O^X+F?We4wT1 zukrmm#WSxFjm3keN)h9T)^?QUTx9+OpuMwWqnx)}hdflX{ zGxYkc#(bdHqpO+g>0Z1Z)05YUJ=wPB;VDdNUD%hh9!D7>?wKo{9ftT{wLV{kR+uXc zB;{{A@%p`9U&K@Mul z1Xrn?hHF~|ua8t6k86p>Tw8HG+GjJ~Yto#oG`v1ib-L0pd~F58w^hu+b!q-NxX#z> zFZmZEyh_6-YPd1~O2i!Gk$$vlGADi$!Z#wEdasvMFNVZV`tW*$hHvW4a3f+SwXW}S z6Rvmab+uj>;W`)xO~~`P(Y)>!UySSB%DGPE4vcq{-JgFuQV;646xS=2=J9mR9N7Bh zm=AIX%dd(1!E;lWNAYob_vVH|%b~-SS-ko+yq|_I)9XaNUOQ+xp2Z!p({UZ`dlA>B z*g9OVkPT?J`vgD8+rJOGRyn_T&5j`q#r0|QG2Wh8Y4kbJyfk9k{4`>|JJRUyNP9Gm zI4@b5Mx=a2(Za~ygBJt6Y3RM!Z9p3xlBxkefXl~*W(V8z{PG0)e36ARJJ>xIm8<$Y zbbYWY7MGKDNTJEGBH0_GieS%pRb_Lmhn#8XtI?;$ddgjfmh?R*)?40G#Inzi^_7nu ziu}^&s#rfM#w-!_5()ObF4kYhDT3C6ZjKF*-3&cA`1aTia=4)nyn8XAwHmst?A}

Q(yVOM`Q51RkTXV2RWsplwOSFm%c|qDKt%9!2!3p_&?^PYs=^`F=CBMDrC_Itdds-(W+>YQDV;O&$3c zaG7D~f^kF_8X7c;=uSiX)ex;RbgAb1#L&ZcOOIV^&~pN(1$&j1D#>$ z`ea*dtXyj7mgI7vI}Kgcqb)X0o-(wcd=t={hA!(x^rfMFcO=T%$?4L&Cs8j$M`^wh zhW@De_BT|j`DPgUz6bN2ZRp|hzX080=%HRj%MG2lBhd$jCiEm~H*|sK>j~on+u8Y= zZ>*tRHQy9Ng_`dSLrXQ^b%yTNd`}oUM)S298m0MuG&EQ9b%k+)^5$s1p@zn4z5@;A zX})Gfmlwaj`>ZLl(h&QsDbi+$ zebyBD*3j8?ZLvdeI!(2XTvWFlsFxx3XNSsQL+sBEm5GMfpB*NL89H)2(TRp;j9(6P zv7tvswZ#sXMTVXpwH)XPLo2}L2w7|B<(lO{pBS1jp)EF5elo-!bE zlD`07G{{vuQHZ&^l-~P0ls+_PCPx zG+AnBIOH8IFBm!%njI~h49y!=5)N2lr1eY7k4YvYMV8Ok3`RBvdf zTB1`8oj-wSp&^cH)8$b^9Mz`F216yv^Ls-a*QUh(swj;r%K<$&<@ITx}nom`uT<~Q0ccD z+N_i-4RK_iE*}}<$UI&AG@_y{{Z(teuOW`p)1}T3$LZ;ExS@Ac&$))Wsq~u+ja2E+ z7`jZQzh{W!`*iu2A&&3UrCW`Y-d}l+G{n(ZHP0+8M4k0XO1)EGeev?9xJ~X8ka+aX^K9F zU2>KK@oXEP%J&1>Vl!oop??in4s?*AB_(aKl z4RLOIyu4)SqgF|<#QlK2U7x1k8QG|37>oc%V*dxkjsZIXW&x&xXuN#1Tw`k!-4;!QHd5NF1- zWN$;98PAewh8}^0Su)qqvCwRmTw|zthm!a#dDc)j=8xLl;8N7J0)^4fJf0 zFH-qRhyr^!=?_4&7U^M#GyfJDV~8{V7HKpT$|E}65Z3`Ma;+h*16t&9Lj!V&-Zr#m z2cjPgo!*0}%brepMGnyrLtHzr)ZiD*Z)6i&gr^hW??_1N%97hpY5bLxZ&J;|+CD$|DRtq0;9Wny%6p8S18b zK5OV?mA=W)Nh+lu{ zAE5HEP8}F;k)%Uv13Xn)4RIZQn!Kin>+oUmGi0No2M3Rd&z7tM$z@^W18+k7OsO+; zS=og6T;VBhrrikCDvueuX3T{6+47U2TY=7%eg8m8uEQtB&zDOSxpnvj@}MEE!!MNA z4RIZQp_CoOd|Yc!ieD&W(x@SRu}n&%sqy)8TpAq{Um$0v(X9BDvLua8i(f6zrqR6k zHL@j*&Wm3wLnf!>T^zqo4tGe`@7K$8LtMXKFSAkz>-QVvG(%j!-ymlh;`;qYxyTUL z?>EX7hPsDd3g0Bx8|o8UAG=9zHN>_3B6-XZ*Y=C#WkX!sFOrW8aczII{Ah@4`WU7~;Bri7YV0b^j7sYUtUa zZLwSBSwky_E(dBeM4P~E@`WMV1a8A2Z>Je;0=G+%A=(6Pm%)Z;6SzZmF?7=~qJs_H zHf%Z2iH63FXp7w`=Nj5&#B!iTh9-_}i`^v;8aiO?a-g+_mejVz?v~Al?yg-9^pl|h zsI#S#)8O=^MPjM+H$;oXQW8#sttt;o@K7hc%W$HehI$My zi9akm8@d&B_J|x}=$;WJ@kiurLn}~&kID^(x}mg>$|HvQ)|JE`m5qkZL!CV)-y5P8 z=P}7W%t@ye=P@ZWL@Ul?GSLvNIFHLwhFVc)kINiG7oyG{ms<_d!t;bYV~7@>C*(sz zwD3GBzZjy0=Sk^uxRZVaYVb)JXy_QUmnY@G6heJICC3?}_2(%$&k(IYPst)flTd?C z$>WB$pw6C>*A0D!I(tffH1sv<>}iP~;iUhFR`;|FGc*}B_>Amp=t0!kGcw&!f7IDC z(rRcF>g*Z0&d_+&*)y`t&;oFIR^BmmEuO=((r)N);Q5>sO%?o|ANdeGpOZm`z8F~& ze@-SFDnOkrlO{v6@En%Og@#T;OI{{_HnbK`Wx2d$Xa%0ba@k^N4W7et2_5O=y$+tw zOK(H()Re@Zmt76*iTYe2hZ(vDPi2LiYG@+rbA>E0)PNdXA$J>k08eFwtS~eX$HZ-pcc9irviJ%zMJ5d z*iaZ+zbJtjPD1{;dGQxzn4#&YiB<9kLn~1BRnlT;7|Om%t}t`}xU7-~4c)EzUN`h_ z&G&ahzi2*~Zt!=0q#w$@N(LIb2z9nf_BM3I_<8YFGS|>1)Zj~UlcD!egD=T5hCTND^bG2AwM;a02x?-r9Bb%Uw1U-gk)h{NpR46ALp3PF zYI!L|i88E~PYu!9yIQi2bMk2IT`m0$4TjdMWoJXQ{jQcH4bk?yTFx>=+wW>wY^a;+ zxh#e7q*u#kL$n01mR}9g61-Y^9Pgz6tog+w&pc*nxSbZ`x?0>g;4f2@|Yo7 zp4Z4*hG=% z5<{h`=RJlh(3h{1_fu(j?yt$e4ROceH7Pm8$>Wa0YckqU4xamK(qQO%l=d|_&CmlV z?Q3$4p(n@Ai@zo>8_Gu+UYE}dor$u)F5y$11ny$IE&~j47vpu=)ev_vUYDZ`oq)2x zF6S6J6>a`?c{G)Vns`Gt7~&4c8}hv&?r^*z1*bWA2cst5kimwy-|>d*Z;1OHZ%C6N z?svQ)^9}8RvcDns8Cr+3zag&}Y8x>x{)V(0`UEwxUV5JH6p7~&qvdb!RJ_fXc$6NVl^+1E>(A?~QGmmdvrM`gWqJ;TZ4j>>u&YUnDIcD)>E zi2Ez+rP&boSJunrhPc17ULG)X5=y&XUNh81_59jUDW1`K$(!xuov!7pG_*m>H__1U zn(tUcM`^x`3>}Zre!VIFx+@ek#*RScf{>ApH!TfG52{CKx&s z&vt`MHT0%(X*IMz%DzEvHdK$YZ;az z?@E_*oV*2S^Y6-Th8CeFHp(2zg8)cCp?mFX)hav7dZx;ThPWgBzKk@)9qISw07Kl7-YnA$aYuTyoMwnS z(wpT9L$8M0VjsxuhBkzk11&Se-Rdo}-q63WTfIfTG{oKNzsPTfxLf@f>2{&hle^U) zN~NLqLqxTPJ`ODhYB2P8SzGKQInK~?Wy^uiH}q`xw%EsVouQT8mjgX)=%(zp*e9~e z&~4evfwmYL0C}Iv_lCGv{;A|%JzZg0#QWF2Gg#YBEb65Q{>0^ky>YvFdL)=yWOb$21 zUG>l9G()XnqRS0k7%qu_E{_{}X~3q~7xKEHY1n7}LOwTid`U_C3&C~>{?3ooVb}R@ z!VfwV?GY`B|4sHa^k8UH>`R$$h`ab-N~!~eAmGQ=JJuVqg|+~NOPjx@9jYVd2BZHW8*U&~d7xZnS^JY|Uc z{ePD?4ROE!@A9Rgol$3hm#j;j^ogjmze|OolTc^h$S#J?98ePfMvgT!6gBv*oMR{e zp5MxihH_A6-^vO@=b_HNllKhKzu-IhhavhGd?$JHopkyad?!N;(ZAq(+1t=xQQGfi znxVg=y?ieh8=~*QKjbz;^gZ~8tT9C2gCFE0L-alPLH=zh44ywo*UOyrIC%abV+_$R z;Yay{p|4QdALV#M^h@|r&Nozq8vIdiHuP;7(G!M#E-Q)uD4Pxav-_slKjoi>e(PQm z|EKg^;H0;qP5dOo4K<+7evPWl6=vwumAp&L+VzsSLcCZf)Mky(Zg#9RD}EHv~U z%KmS;-;fV=_HS8jC>w3?-}1Gg2T+5*O85#V?+Ltdzsf*E=b}E_Wj8~AL)qKqa6>Pm zKHKFqLu*lk?Q*%Hdr)WXveeK(&G&*KdW*Enhlc1a(k{L$opgGOv`ZgD=VlYt8lvw= zyBubSz9a22#}Iu-+T}(=w?&AaHbhU7cG+l%o+Rz^vmtttv`g_-PWmHZq7jC^7(g`H z&~@06ZkLk{orE3fcDc&Xh7i%ihUkydF6#}^AEjNsHAH`ucFDim$*b4$?PQ4FDebba zA$q5@%khTjozgCs7@Dr-yT=fHRoZ2ZA^NJc%jbsZtI{r!g-+fHTD~0&of@7OZfzA@r^-Vl9b#J9yzHOel&-wch3%!`X}@U>1l{bqc={SDD? z#^>Y5jX7G-Z^q}FZ)h}X!solsP#MbZ^SxrI6lM4M{${A6WM16o>v^5y@-JxZ_l-4l zCd%&jO)<0+%I^1_VW=m{?)P11=tPv=?|Z_~W|YD2YcsSdN%W(k=b*LU*Y$cQ?-Z2X z?;C1pUe3I@-*>d3c9bFDJI~NCls(|P&Cq)&L%{cfp^s69fbR!GccLbOzAiU72{CX9 z`i2<#tc>UYLtl3%I?>QNls)KMU}%4oJ?OjN5dDyXzIBG^hZOXEWr%)AL0|5TPTp+g zQelW*NkQLUhUk?P^vy6tucV;wLPIy8&Vs%>4bev_=v!roK1xB~Cx+;w6!Zmea`KjF z`T7~U2W>v+JIK&q@Z7U}CmC9RI?M81Y3NAQQkL%_Lo?ClvwR;LT8pxWe1Szy0==L@ zzEVT zc}fTE6cM z(a$R6D_HF0(a$R68*GSvRw3X1hO)JMO@<1x=fy+5TMYdlp6)xo=JSgKxbOEI5()Pv zH<#9oSTReD*g;AcTBCznHEUIEwL(<{F=EAN2|me4hKg>-nCaY7ds5wHMMJEI;cqq&--E)+?y2r1i6kEY2bS8}F|jS$u9W`&+q_oC*(R)&5o)XrN^G zw?2SMOLl*&JLHw@{#FdsQL_76OQ9ZlM!Ws3-=MRSA-DAe$}FSuE^+E@mJGSAs?fKR zJ-5{odLkKeTm7LDl0CN-4^;>sE&tX)Ds);h1Xx$0g|ZU?R*t1kg`6@=fE5Z|me+KE z6$#yx*K~l@9y%!51FVtI2a-L&`U%nww*YGgq#bSn*6)yZxCL1Mg9eyc-dg5V*M7GE zD+1Dfw*c#NNc-IatU=I3*`)w$CNx9d%LA-~koLj_S~no=g$uOk7pH>u!UbBzp?Q)v z&}s;sm*+ju`WlLsT?({DL$UHc7-+4B+Q^>eu}(s%l0A?0H>CY_c`RwUk^hbN*Z#UZ zRz*nr>+)Dlp?Z=%kJTG`U#|H))&fX-@A6u^Anm=&Yh^*&dzaUG4t*{=k=H7)+^MIX zczLawkaptbwZ4S36ECkd1ZpSQ^IEf^DUv;}wFyd)Yd)`a3u-Jo;j(hBa4Hm*SzK00 zC_%EjtoI=8?sHk4A?@ySSwBG9-RH6vK{+J5%lZ}4UO$(03DRCam-Q0TUO$&rc%@Ul zn51=Cb)Zirt;_lf(#}7ZH4M_uKbJKZDkEuK)>bG$(z>kQpdd5u33S`6%$wrW>t*s) zg^HMbEg{?F>kkDM9qo2mOQ0_#t=me4GGx_m>ngObj4H<}r^1mkqup++3babH2U#tk zu97{->IeNP8G@{7P(jHaWUYY?ONJop7&Ke52U&kY|4D`*D{!?_?>Bi(2U+Ey3-X!{ zvOa=#N%kPC2NY?>#X@r>dyw@DG)J-rSqGtek~YY?0rfPq&>E-S7xLT&S;e7nxsrpd zhLCN>eGLW5`(ThY0~#ZH7HqAD)=Ju7>m>BK>{77xH`JlCqk@=P&wI&5NkQ~LNbI{>Ci4&b%=EfYAV@7teoqe3d1FPh*c8$QQpf# ztS_K2nZ;`jhHgj(uQdy5CE2~!MrfR5_gbf+x{}>%J%mEcxS$PAy(yC2YgK|8NOrIF z2^1mOz1Fu-eYxhnRw6W3b|Rm(2YMv(5Cvwf_v;Ko#$u*zPdS|0k?}TK?Z#9GB zWhe4meW15wmi$&6^uFvwerqN4L^9;J4nx}{Lw@Tv6e`*CTYj6I3YKKgZE>?LW_AK7?L|E(u<3^@p-WYpwavoH|Q_*IT=xC8CYiEhF8fRl%FBpu<|h*}*MV zbx3!2tMwtI&(T&(|6OM1df5=X)tcr=u9t1W+pIaz`ck_@i{7Z`!y$dz3nI`%e8d6Wy z1q!XD8VN;4s%AqY&A2sCk{Oo{H8JC^K)uYkXHd>^T0PHEtgMJC6uMSqgQylXzs$bi z-Bwd*ahVOG?$E5l`-1ma!=U(bX`&g>w4$mM=##Rl15juY)kUbQ8TT*L#f)=jI4dh| z#+88{7uInNjhuJ4J=O^4Z}RFmDu0`WN9r}d&$R$YE@GnWtKqiieql*uRm`pmxI+g3@ab3;`PsAt0sq88AmTKj_USY4r=wKjsjlw@ z_pKt(Gg;YvD+02l-hHbbr0)}dS%V>cpZLp~3f+};{$(XY1w%$JDtYx{Z>~HH`=(w!xZ|h^|w|Y4}e_Q>a2&wnLng|V*l|8VMpwYE* zdLCGZpafaxL+dJ(U+O)yoXMwK_s83T+S#gGL4H3w~-%hV)(dskIE!cj2ej9_V@= z)j3Gtlb>1-Abn4MYUMuXtn5*ssyOsKFsJ9K^&ylTo)-K+s}s~%-jn}l4TqW)&guD| zwFvT;S)N(jA$^~JW}Sxgeg2ts5Bfz`_RQk*PW3f;a(bRw1V)ms~xmS zDm=FaL%n2W&#kG@fbg82=hh~ufQ)-#9fK|esBS?uWo0j{*HCF$*$eCK-<`F*Q#7aN zg%t@6mv#PUeF@zxqv{9UFO$>rpOpZmOTCxYT4;i-^QCnNij;M}w64AxS4j07S|=-e zX?g$9>dt4}ORKk$zLyu|m)1x}^1Uw5^V(VmaafT2$0yGk>1SMik8NLqT30UYA^V|E zB|Y+AKgiMw3I4816+Ag@KWIVm3Z7hcVW>lisvbYPo=x$Vzy-S)JGm!-VVL! ztff!b=bnmoO`qC(D%mZdm!VBP@7lwC`rK31p6640Pj!16lr4{}VPEy>b5AW>{+Uzr zJYe5_I>>qu~a5c5(`Lx&7%s%W$zCrZ% zd}5!1)|dKD^v4?&?Ctr~z6NbA_?_q;RHWu0PjmYfRKA`n_=>J`ynmH?X|CpWdFYHh zvV|Q9Wz|e`wXj=5fsuneE$!Y=^P0m%?cqK`CB`ivYnMZmA|#OgPgd8jL&+v4#dm8Ec(p=Bi z_ADdkeW{bZ9n$xu&UOZ*?@OKS+PAfyleV+{y^+34y~myHct_^5v73D!($B{3cF-Li zr=N`rJw5D#Q0vM|Jw5HJKCSZfvYSG#N}D{r?N6Zv#W#8S*u9|+C3bqgwdeVC!1JBG z&8G}cfBPI1Adehq=e+Aw4=8cKGuSTaQ-)`#{T>urXQyYF-QTAJo)LDuPZ^$3_FAZW z@tvO0_C=o#cz&?|^C`pgqh08pQ?F2^ot_x`BcBdEU-Sqrq zTlbw=vgMHr>^eSO^d#EdeY)vcY)^;g)H&l>VyF6a(X-6H;nPh|vYqQMXU-+^$Q5=2 zpDub*?A|`z^sKfMjPx43?^$b~F> zJsa&wK83kA+e>{4cW&+LHfD5%`W*y^0~R)t^n!h=61U} zG(C53&knmDG$;3WqW2;FOxf%G$Vr`-qA&(xjvcxY^cL7rXqJZNHrG}kVBBcz|V zyX`bcKW}&2S0Meo-D5w7IyY41dgvtT*)Ywu$1VnKsx`>-t6de^Su4%;tKAGrm09-M zUqgo@(_DM);ZRi6AkRK~3Z$Rt`|MvJ{Y2kq{|X&zpgIrf=leeUA*7%0`|N;6&dOFd zRF!}>Hw<&{vp<4f%eYj#GnBW!Y6O(8ewaJeUJMP68s^z=?|}3>!+tvx((erW?LVPs zvac?ee2kipTZ}r052krV$gw#v3TSG%-WodSQXmqVGcbYv1 zN|1G?+v}kGQZL;;4E2z8rrXz`A+oY``vr8&JktBbS&M#OO1G;*`h6+gZV6p#pz06l zx2JSF9@1}5>GoRaL_^haD5$pTK2)i`D$l=8z41}1@{oS(O1D3T^jlZD-4iNg=9~cO zcd&Fj8Pe}y>2@0Q!aVXO)XY@pr%t`Lrg{lzgQ*?`Z8z0BK@UvzG0+EQ&O~UQslEqF zHq|de;bzYNpkbzZq5nD6V@>ti&?WQ8HjsXEOt-&>^qXV4JqOA*JHG|e?~&>DSxCP} zrrZBO=gm4po;mdjnCexanx=XSXqu_s57O_S>Gm{8zkjCNYoL;5&SQ{%8%?+Wg!J2J zx*ho3smEr{a?ls1`bSV#Q@sbY(>yX3N;TDgfu5P_2chPs`VA<_RHqkCy>+H~aj3GH zvmrFrRR0=^H`PZ&+2)Z8p@F9QZfKsVegS%5cK!tvWvUna&#B(YRIdeHHS25*6*tv~ zLJdvz1Zb|Qz8Ok1)z3f&P4&l6MKh=8rBko3ss1iB#Z+$&nr$XPF>Z_px z=8+lDQB(aMWScnyUODyJnCfMr6jS{}XuEmjH&87z=Xfa2R9^S=mw+ne8RR)+M?evI(p-n^=8*ofIc#@<^q0+HdlaOwFR zP`yCa5~xvNn(K(Y8+u%Nkmsm<5_(=b&2`ki3ndjALz~_%gJgUKv{*pRoPlfcC)G<35dK9SI3q21Eb04#BLCN96 zJjdv|SBKmwK6Y zGiZXWGt=%4Manue?a^<>6;jQG*2&5;?e&oUGR?G4Li)=z)BYREl~*N=tERgCPR+C{ zLi#&3({2it&ZFuL9SK)Wf`%1Vt$@l0st!T=`!>_ghV=JsrkyLNQ|}KmXDLX3?Pl7I zApNzQX?KCnnK{Qo3r+RKP+e1fFZ4P<$6bcnn(D8huT1qKxt!_?O!c}@0aLv#bl1!| z97-|O=RuuJ^=(j)dE_}L(^UT#(%?NU=ruutO3sb!_G|N=~0SYqJ7eQCeoWDXzrurqQ zwWR&;NO!Z;VKc@OzsH3UA6{>8q{|0%@BcDLqOm%Od zQ|}v7y((0|tg|IF*i`Qi4Kmf^p+jcQwNO=4{W!#?`hDo2nKMrwr`}{!y*yOTRR0*d zZ?gANlbDW=_uQ)SG9jmw-Ms)uW(Sh4c#P1no1`#~3*$ zCzPyzcpQ6kh> z#@)16L7&K+H|-s79w}?NWv4^MWzJjnS*VK4nQdQ#>d2hg_5wog%@N(TmqP)$LPPG^x1o+@N`?GsFAH+Ubu3dUw0Ft7h&owk{AYjb(?a7d<(ib7G>}k+~;$MWkwl_c>O0*BjLHB*?9%7T% z>s07aqHhRO9VkE^nTv+_)IG$X7WmXRB!CV;p>^7a3q(dp>_I(1W_}n zd-3)m9-81&_Yg0w^{H=2e)5Cighqr72`Neo zeHtB7j1K!08&aJ9f%?cJOH$RhoR#$n8y!-bI`|YDQidi&FGGifl%s1tjSi_m1>SbX zy$p>Fd54-q+49KB^oviUL#oh4pJGF*k+*;|Zcd#cAraKdr_mubDcYylklM5gS|X3E zOLjqL+>$z@Ln5h`Pq85lsGpHugVREyD8tCP2H&HHke+olqM$-Li*wf1hP6t3sMmx=%lPKBa7*HifhxyRcT!Ib+eMOZZJwIzpQIMXWwWW5@#j1lm?dUt`M%6S|JDLdT z*;;%08Pc=0_Ou1kv$YO%7}B$~4s-$1v$YQNFSNVbAWuiiUDR3Ufof^4j#L8DbGNT4 z0@8E0uckf$rXgj&{3b9JS+LY;Lc)gR>PM&+Ot_0wG4s1a0J_N+U73F)a{cj^b}sb6=R z2mIs@q$V0XF?=^0>m%2mu+i=F{?rwY(hndKX5485)v=Kh9y zzmd$@gT_I6KG=g6KzcsdgZ4vuKG>5kKF)$y{H-#Q&-gt zno>8+-HQf77o=Wqnhf2LdcA1_)TZh%Pair8>3L)yx&i5VWFLA7>3L)y%3s{6uIG_` zs4n!LWdD{vhnh(CZ)wCEN%p=p3(~X7zO)+Bv&z1d233=^ed#i!CzpNce~_MB_NAgF zoa%aV`5o1O^yKn8Y7TiLR6U`>5n=A{Xd1Lc_N*T*hf->Ux%<&^C{=c_KV?G&ByE4n zQPQaw)*#H?pGre9vS$ORKBOn51E@8mC#3_ZKcpw6185GUC#3^v9W+MvY#<$mCdi%* zq`x6OMIA)`rJU+|iaLnif%Fu0Fns{&De7SQ8v0sxa4-#nddT%Mm=fPe_HzhrgY<-T z2%UoTgmnnrg<8lC4k0S-RKFs7HiQa8w`I?UP!#l+?AcIi3;ios-B9`g`a*W_dzufe zmp%KQHbNz3&%UP&sFv*6_jD6#D0}uj1%^A-<7Jj%R1%si&%-eK7`i8O4yP{AHJNib zjfC#j408{sB*-IsHiC9S{p5KVL1&=v;gUVD>ErIkDJepD=Jq3@ZtB{_8M^lb+PQ9S&s?ax* zGqPxkg!D{2n%YBpCLT>Ap$BH%Ptb%K`nMg>!n&&8p(kdR|3ST~>fhcf?^M^5^=OKK z^khAnK8N&VJ(>nV?M%LzZ)Eaqfb^U_nodD_&K^wh;c{LZfKXJ9ZeUYOi4SMUO-jkSs6_QD?0V+G>CDJrlwGWWFJGl zAwAb0Lz5sq*B?VGAU)R~Lx-Swl6?$izma4gL%H5@D(ETy7%BznDgPL11PznyW2g%> zwt9?v49$M?H`$3FXcMG&0DhoMNbdmrK#!o#vJ*d0a3!bSGs*q~Rfhaz)j!auP*A-X z_Yd>~^o{JqSXu<7O7^kzD^y*wkEKgcOPOUXy@XbnafK^8_5L&C>Oe2dxUZlRl6@=< zgC@$JjitHJjD|7pv2+BwAUpUY-GQ=X2Y)30cby7%WR@Q(9C{QPHU>)^e0rntTV8xQ%~=8r@pJ&{CfUc+^*562RM5LS<0%Z%yF25l0rZJv7*8D`z1K6Iq9MK4 zGoBVedaq|Z?SksdRX(1wpo5ZjJUxdlNZRpKAi}BMNS>qdR1-=x`M!koj?s7;0_h#2 z@iZIKJ4WMa6O?4~WkM;{V%*~?XAP%<-c5?7l91j_ilz4;y_*zEouN2M8%sYxdT%L~ z7D0M%DVBbP^xje|U4nW`+E{uC*|O?bDqPd49#}KR9ZR1;1th}+`WEUZ*(cCs=%{3$ zKq*k9%rb$FKyA&qJJ1F*&cBvZVW(Un6R0+nQ+8q^wSic&Po(dm3X**y&4GTB9h^v8 zpqpmgS?Got_YZWxR*ZWhm9Fj7dm*z-qQ+1c*@;Qi6*?$8IEj9Qy2Op$1ZW6VFYRjr8(FjQI+)bkSklwkQ zMB5>~b2o|3L&eQHpF-v3y?hdtt?Sg&yLxf-A*6Tp;^-Sl@9M?TcqmYw_c&Sx&6l)s zbO2f>Y2)ZRw7qVOJC5?zb1DQ$hRIY78Y$T)Q!7aC6i%iAklrbrOw%E~Q#hH{L476r zWI6#2m1}-7S&>c!y^A=7ia~l8aSGLk^e*BQ>HvKyJ28buL3&Sd3jGY}J;f=s6ViK% zQ|J%qL&-jcog@`cP*z<6r^`8r&1?K?^;f!G0-eYJCzb4y@xrK_CR_M zb1Geg^d9C^`VSf;X{S=5hE8>_S!ZpixI9NwsSPyD`J}~39KwZtav(UHl zX+M?xqMQl`WR_`E8gfb6Y1A0XD`}@uS17-{Hm6YnR9&*i(`IO?>{&dWf&P+N;^{H; zugnrpp7)%3EZO7fU8s*_kEiC)aoMwY`VQ(V&vZOZg+|JA8&9jDOfyRc)LgR1(>%b?vR-vKC1zN5s`zmT7-dOGE6 z?9}sqSgNn#Hr&BAabp06jbc%sSN%k4E6v~!aX3&0Uv}B(_*Pz*weFjdLBTQctLkWS>bBphmT0+%sth)KXSGi++bzNcLIuKd7W!^Rwu! z51o1&CHpLjfR0G^S@bznR-V6EGzgj^*=NyAXq{xAMH`??l6@APg0khBpGEYMQz23^ z%% zfl5J(%(zC-V>7M`^vH}G3l%i^7DHoXml9|%lqtKEK$oG@vP%i{3c6y(6=~{JZ(;J) zg*KXfX$!S7`G!NCOul*0HnU6Hpz3Cw=OFFuNT9orc6KDtOGrCA66mdFPIc|th}*gtY5pKCOqe>tjA0fVAslK3#&2 z`3>^?L=T}eerc|sDDV?!W!e$)GZlrjBjjhQ328^j0%`(jN5}%|25CpgLi!%Mm`fE0 z-N=>ZT1d;Ft)&Ke5@{Q>r&O9NkxoOq3l8!uqMOixf@!Wr^cq@_dyr=_c|LX4xioj0 zYcW-TN=v;Yst;*DN)ok(v>zpj`a{}}l0=iBPPtV}Ani^`qFs=7rzFu?NV`*#=r2gS zQmvXuHj+PSinVj%5YSxPC8cCIX=1JD>h)kSE6 zUzmFtQ46PDdg)=FU#KwDO?o+gq3Y1Lg~HswP+O>hbao`uAn3z@Fn2P|ht}sB=2=b~ zA?=h|P8pDP$}Fdwkao%}r&o}6$}FdnEuFQLlpS0_^`Hv5!`v&V3#45%D`_O8T{J6c zHl$rND`^eXNOo`~r9;|Nvy!er+EcTValR`D2_hipfs0GwS_AG^l zLW5+_R#7}OvUHex6>Wj4$qufj6A;OqtLZirD0{Y=T%S4BV`R_PP&lMLJZq={q&+-q zs12k&JZoq)q&+-qX)cs4Y1h(v=x@1R*3vmh`+L^W14#RO)=~b?@w`j+^;8Mc{+{*p zA>=1>uBT3rTjpF(v5@xuY@kKZeM!54wnN(cvw==SZ^;gBpnK56QY!w!ss5}~n0o`2 zgPs-~=GjQ^L9Yvjxi?Zz=$u>=n`kW5M)qtI{RHKaJ=;W^pgZy!+eF8pheg8No9HoA zPxfpx<@wU7-dL`>%~S>2DQUM*6KJ{Y*%s;wt&=_5LX)7MWY4zJ5~zvn*;d*GwUSry zR=NgdN%n2@0qnOv&ZkoJ7-prUP^>e}g3G+4oQyR7A4x zp_@>D$-am9Yp22`$*_k?Ko`oWqM$vp>OIs68Y0>E&=_cRV2pbYZGc`$hF|FvR9&+F zN)Mnc$?z+=J2~}kNQPgjA(SjTv6sGvTr$gE8V%hprCJF6RZz7XIxgAw(gmowWZz3K zAnoGlpJdPW(ql;b z0QZuoi&Ibg0Qb_nkoE!YrRGqQ$@d+UBG>$0S`1y4=Y1dTh2mw;_R(div+UA7dIj~C zYknV9?&{P#D%n%%Q%E}vQ>ibcorbA21=3E#R9XdnD%n%%D5QOdsdN|8zQa_?-OZ_> zeTS)32I?T$Q|SXpyAV^UJEUERsT2cg7h)v2D&LGMc1{d5xwk+l1XdpPw9myU7orwFKk zWIsTkLl0%u2WSv9P_iGOnNVrTetk+CJjQ*HT)mtMrzJxgRfHDGPNY#& zD5uPlM!liS@|sSgNzhGsO{dWc=%8dzqeIXKl0A*GA?+efqg=h6dfG*rMx`L_B2A-4 z&;T<_7f5?b(`YQDJ*8>17}B27G};SIlwC@r%g_vYFHfW3K2AODH%+I?koKFV)2ER3 zo2FA=Xr81^rzy~RdEV1$6%;MIluk#XSa}~zrid6bos0?GgF~((c+LbP&?++9PxWDkEu+5cPNJ1xVT>R2&L2;~GM@&C0%pdYOEq zp&};VLdZ7xc0++hW86pRZ|Dn2dz4%QoO&6u>Z4Q<+E+%^6gpBS#(k8cp;eMSgBCzt zC3^<#g8q~Y8I%PTl((j+6for^0s0ew@OgK9c=7y$|)4?8m7av_&!;r*Y7)lHoXQ zfvU+)oS?H%IoXL5^bhnxGMu20p-#PBvg#951!^kUPf!bJxMV*;{h%M^z5E0vL18k> zN!kb9kPIj33e-xnpQP8&ILUsJihl3Zt1H=0QavcdjB5u?k?bdF1k^yXpQQOvgk(QS z+oAe$&7Y)u&{)}tQ*vnFRCp>GPElFtiCpui=tJn0T=S=BICMfXoThnDyzInj+6KKP zvz(@L(EG9zr|DnliDWoU`G!07wn>K5R1FH1?5C*}WJ&hZGyw85!41O z{WP6`ypsJi{RKUfYxgwe9pTh#CTUMo1!%unXA@|fq&-c&pd}{XM5wLFw;Z}|)|n0k zn|!yRy=I*`M>_Q;n|vjq$7Y@HLC54ZeVRH$nev)GO+P>zO}<4?bu;c)Bd3G?G`)2G zCVjs(IjkGuW zzmUf?$jIr<{)gs4+ME4^Rzdpz4|qbCjkJ$C!}Ww7`;_MTmvWCWyDNX&>v~Eh97!K_ zA?trs9$H_@%JDy{_C^H@S;~6!Eiqy2d(3zMiT#RHa^q>p8_hXXKGD zXfBjhGsE?QHb8-qw)a0e2sN+iC%Oa;si%4hh1OC9{oq_7k&&vh&`2{b3Q98L+Cojt zxIs`aGj0mhsb(ITWfc@#PjwWkTuXHq`ZQ9Nd#qD^iWyf1+Gxgo0JSsYxJ`q8PEV8#`LcA0VYp>Aee2WXrbHwwyGPIosR$||B-0$nST z;rfquL-Whn-j{R|T3jZ>^^)#FvkKeZSM(B!FXt!9Kh9a%w4$m?&?jY8A3~u;RGpx% zX54V7iy1c)DsINDf*u#vaR;G|ieL@m1CWH`aWoL4q^d-~>CN{`$mFG>6zJFHWbV%PnEAUlF-#;tzKTzj}D!<82qMi*iTopMC+EmN-zQfg^ zowYJt@9-y3s?1V}J3)sdGhCH;1QZozdn@x)NZ+9=b26mw(3N>FbgY5ucSzr_EAt~r z->)lk;1p+Ns~f6HLYo_gyDRg@&}$j@E_Z?Q)>n;$^3@M_zspI`;3z+D72XNyXFwG` z1LO z8UX31VFXWy^wThc*Fh&5s!l*bwN-yXmFlbVPIu~!k5W~D^iwo~n?U+08o|AwLT1j1 zkbb5{@N!5$QzJMXdSM=U3u(e++ds)q6ra%_Ap3sit}|^vqOGgPNP_H=!g`ofDjT>rC|$P-Qb` z6g1XU?*zr0>SLg6^T_SP4(}g zhNk))Xs)Tg1xhs4&q4=H^?#s>X3mhgPQAXSdKGAjsonz0Yv$|+bu`tdLEoC{YoG(> zk;kB;ruv_dZRQM|=hSOss+WUOO!beT?dFj^pju|mSSZd^{{@<7svm?Nm`C1#^xJ3z zKZW$$Xaon$cdF~R(FiUHRmx*~Yj6!HB2R{^2DgCpn`%w&3h6i1nj8)3H`Q7^9jX_o zS_(A^%y8A>J<#LQwzoE)f}WSoaMk8}P*Ne=TZdmkD+*<}>hRk?IqMu1V0-Iwc}Twt z*X2f#eiyFG?V;;=R6`*BW?Yx2LHfw< z<9b{GYF0ShU61QS{xVA>w}$lFb|m+Q^xJkMPlA4tl|}LrXic7QcO)N%^qY8nz6R+x z@%sD%+9Va~vv+~BmR_>5`dkqj5FYNX&#j;WGOhvlfi487VxXF`vIe{mDlIE(z+0hr ziiW!z@I`32tg|8i3*9WEaxZkM-!BvHZphW4bg37`pFk62ol*P^6e;VB;xTW=6;jQE z*2&7Ecmt%rG@|$vq`x$x_yLqFugab1RM+1fQTz_1zdNG18B{utst93b4ZVc(KmniNEoilU(2rV?#lc2h$`abA& zfR4KYwKdgWLtmNdMHf5O7nthxpaQ0PJLs;Na|D!Ps?Ucynd;l2AoIxcP^PK=6w=>Z zQJg=?si(iWqPRNr(p3Kp(qCXvJP^`fU{O2+DqVHGU%{pC6oO=4p zEs85b`pYefn?k3|oV_9a9T&xuApIQ|#VerWX3j&DfeJm7YsxOAFnmPAENv8T`sI{s73i30LEV9h0e#BI-3oSO)+d_*>_2JMz zrusanqp7|Ps%)~KgS_UE|3ceL^?biL^}aFHt3d_KI$J@5P4xlLAX9xhbjZxP4ytOZ zpMcm@{|h>3=FFSy)SGOoSAfcy>P?{gCVMaFBU61M^r5M~9Evm5(;+`o{T6iI%$ak! zQ*WNBUK0A$RDTb8RYo@BR{cnMUf(m?O$d%`@3o`J)JYoJ%}TGig6F-uY4Nq9>U`t znRC;jJQdROf}uR;jpV%GdrpM(yx@CY4(WNpFkT1gdBHH=4i(H*$QsW3pkleK9K-nt zq-O~u_#&id2_yIcq-O~uIAE>G;IC&1Be@8qX9**@26VBi?H$D*LpQ2sxJGeTNY5Ff zc_^gk4ADFt(sPE3eq!(A2@iOvravO z_<_qqdIm9;BOyJ57|X38J%jj>dqWGVtD>Q$)iYc_@?5B14cj}8Q=mpQGF;>MAk?I; z?Tz8{P|La*t{8pSLt=R?q~{^AdkXXJ7>3K*j`)zR6@>FJ-z{Q}~)xzBq`29DMIVW;Q zNY6?p^7oLQl}zM?ke-!H;;qoR>Z+5_M?ayWuPf_!`*TG z33Nf~P3CW)8&YpFPl4K0_47{QUm!i3nZmz9dNwnK&qI1PGld^QdNwnK-`eC&~4eXIb39$vzEVP&*pLj^siiXbGaS#h3w!w9t^FQJ)6fMbekO7Zlp@bcA}@rR$bKgBR;Z2aU?QJ{*2%My$akQMk}r|%olbQ<15A{! z7>@J|Fp(QTdIp%t9icMSRMC*02qy9ZNKXV4c^9N7f{C031yxr)e>6v39uY&Zip{D~tFERHs3ldl85K>eNe+?2Gw*NY6MI zb2muOI2ZFcNY6MI^Ac#DWM9mwZzS0l^HoSsJQs70y-o!^@m$QI&@joqm?NRF)#KcY zx!;?=$xbBkG)PZFlXwlJr=dxF4C*X9k;H#O&m?;i2kvv~`N^u2xEvHzFV3CB?VxXD zCzkLCC{?mA;rUQ?$-acQLoH>NC43%QVa7d${xjq9r#kgsm~qvi5|Vuhe+EsIJzK&9 zp&1S1+)H>FbU}7-DIb8cWCxe>b?A=FvXt%pPK8I2aqgvD4f;xUVi~uBcFBG&;{i}R z*@FM$^ZUVh4t6s*v zAU$hd#uFhuYhK37Aw6qe#_3Q2v(8&@B+v9R&Uw(Orzg+LxFn<}&&&8d=$RSU8M5V? zU&gbb>5}#r-Uua2+F$rI^h)OZg&#tGa?St3CDNRF10{PhM?reFoy?seJ=;#^F_4~Z zCvzgyO|mERo;Q;0$$SyglkQ~x57LwFWGrp3E&EJv~q6evqD?C-XEY z$>duDrBsV^C-apzf0MM!`8A{`>&v<5VW*y+tS{$!P@JS)&g~#QXJ5`EAU$Va&hsHX zXJ5|Sq27{qIiH7YS@m*$3I*1Tb1&zzN1S>EB*O~+5b7t{SMWE`QOUl7$3u}a%L-lw zwKd}oKpV`s>(EZQLRN6TqfUjKvJ)%08pM))CAWeqNcNRH0QyOGa3xQNZkloHpc`h~ z3Fv;UIQL4nGMox8WR?^z26d60Na6a>LD|6+?f`X@=RJi-K?CJ^PvM`TBeI_F4iHK5wE>JtZ^+rne)%*^mcTHAvGf3~6tmZzD-ZfdxaZq2$zM5A;L*<%Z%@^MM zO?F}pzku|P${H?s(y6C+RMv1U=u6p&HQXA~`zvdBD5UpS)^Gx(_gB{NX6Qr7zJ||0 z$0hq3ehi(f5$9gRrA|5ZZpu!q7}9$-Yk4oE_iWbk zWoVORU(2r`y@Rusi=1}q=^dQ4To=+iIBU5rG)vO1<>8Rt&sod!AibZnmbXEAKW8nU zg9b_3wfrySHS5fm>C`JO&(T`01`RX$T0v(_z5&n&X54hBs~NWr`c^*e*YY*!fXuRv ztusyqm!w_C#h|>Bb{*G;^2=*;9ruT-OZN2~4=t5FThD8uzhsv6d>r~$W?9eoA(rgx zInP%Xrw&1>v;l{X=X`=noIWeoCdX)?Cbd^6fN1; zGymq)J1c3|a|tNatTPGIf3fp(jGiBOt+M_JFOAU|3427d5H@=R}F_c^Ct z5n1O3eg`UDKhC{@zlKIh_KiFm%9dF+@P84Y~pNafb7I3&h@)f;gQU; ziAzCsW!0Ow5mZOAZ{jXcquO!qO*|WFDXZSho1hhveKTi5CFPpm%#Warl6^A=|KZd- zBH1@{WvHw?f1CMJXo_Ut%zdGCl6^Bzfi6k*&AbZAmTP`9UxFef!xnxC#Y^@rTsX_A z7c1Gfa2+UKuK6weEwoT}Vk=LEib{s9oC5Weo!H7p-bi+0E8l^lCHq$Pzu;6jYsQ5` zZ6y0vejj=u*|%~x=#^yO%HyC8l6@;LfwoKbt(*!~knCIeD)fcQ>Ko0zL_n=f zzR#gfCf^`vo7ttAP<6A;4UpdD-pU6dz019o&qI2bdn-SJ^e*={&Ux9Xu6Mb&aVWIf z-}Y|jD$pkX4A*vU3h5p29o!MpJK#Hb7^HW=ck*ON?||>*L`d&|@8s>!F+bb8i!-1z zei^P^d<)XMsKBF zZ7pSc_wr0=PpJ&oUS19DE@*rA@m}aa!3@_v{sUT&+xDjNeQ0U!3|A`qU3FGgTI%iR zf{@;u-_KPcy*Iy~n?ibTem{4EI^|YHLwcuvKhJ^mPW^sf2kD*q{d^eGJN5hdZ>XQY zpZ5U!Uvt*-y??m-0KWt2UHpUm0i<{F5AxTL-o-!2;~~9^pT>#M7(dlEXo6q3JB=?v z>81U=>HHMxCik_|Iq14m{o6v}?sSfT8pvJlL;NZ9VL-V15D$jd=koI&=Bbc&4IJiV zNV^6O^Ik~11`hM@kai6m=GRb3*})_H_6?_ch1}upBOD27N5N7464H)>qudYDj)J2+ z5o#nmc$AYM?JqdWJ0a~aILcQc?JvmS=aBXnWN^q$r}}%cXBk`pY9f1kLr8laPIExEQ(b!nN_RvIW7$?mpwbj^`UjLXXm&Z^pot_d5(sf$ex|&IZ!Kk6`$wbP?lu> zoqvNY*|XpIZz#81gTHgWyH53WvV(u{JJ2?H#r?sbLE~gUv-n%+o@CGB@z6op&n!-a zj>-;Z@ir($_AHA}K^4uoyO4ILWO43$PIc{0$>K7QcBf?V2hh0Os_u~Xs$_8tq`fLx zycE)2l`P&5E%jGjgS2xci>*JMdfK^?#l;})T*>14&?Y}s2k35T)hKA5+yT$xpP@l= z2Rw^+LT7TR{(!XqC5xXy+W(TpZ{K&SYyV3Y*MM4^d|yD?4U@%#A?=3A;#rV(!({PB z=o^#oG^D*TS^N;v-k2;7`pc=Oy)ju_3F>R|eF6>ji*sl37-+eqy}*gkmy-4Z?}6Tt zSuXHJs764X`vSZFb}B5F>=*eRNV{(?ax+M~Z!U5lNV{(?avW4hvR~wtkoMwSFcOD_r=oQz2P);wsmHTr$g5{tCKXN;M4ntDtHwbX>Au<*iV2$$pi8gS7AIDnEg= z@98Rg|8eSR-_upD3XL$cw1l(^>MHk#vQd{L|x_kkoH7f zNogu&(n#NPDoZ^9@LQu&y&bb?SAH z?AN(Cq#aq;xgn$-S=ae%NISBw^Ju7>WWUY}p+rf0op(c@NZRXs0V-w2y?FCTlds_a zoa)-wb)9QL+ShfRTSMB{b)APoxlO(V$dfzHeVq?M+VgdTvmx#Iy1}`gITf_$>jsyC z%1YWB+z8SRup8V3(hjg2JQmUpup7J>dRNlk;Jr|Yq`kqHp~9u(+&4J*xl^xzWWULk zp@*{SoBSy>P_p0TzEEk&ev_v_UdeuwS3w;m`%OLy^~e+FzR544vy$N!7k=T?%Pga+ z18tTJxA-gQTgiTlhe1yy!!4c*m5}VWcq>#PJkEWK??9&|LpJ;W=TulIJCV)dP)?a8 zo8N~n%WFEDyFoYQHJ#1lpo5YZz+yK(f zzq{NK($2rT91Usb-(6k+wUg|3c^5QAvft$_C_%3IyYe4YbLusgow&yppu#fCJ#GRe zNcMZ&3({`Fdpr@+Zo+%K9MW#Wdz=pCknH#P7Nos}_c*8R)YIO=dt4II-oktQ9#l-y z-jn~Pz4J)zG`z<@K-y_|j~7AOX?Tx+g~~|UdwdBBkhJ&sB@|@F6((oS+h%2Tpk5}Q z{5Oxx&KEKHhC%lK*}D&TsfxSt|FivE-~#S#+{*sLoqM0&?d|*{M6>+SzLS58XtAI5JNd}pgW>n@_Y4ZrK%z$m zo!;%8{I*0l_+wv|KZ0m?f9%Wh7ZSbWk6~HCVJQ(!?OHJqM!Ig*o}m*p1_C4SA`%|A!< zD}S`_=07Id%CFM9`Q}o(zn$7o@AhtfBckqp&ECuZkmwx0X7A-sB3k0__xJMG5FP5* z?7jRVqVM}PdoTYQ(E@+$@8z>mx`%!JvA>t^OSF$a_V@BJ(Qo}RyqDjZ=x%=u@8!=W z8s}GHdH!aiA$}#6=a&%u)gQz1{JTU8{j8Vg>+90}&G5&*JU@)+D1YqB^P3XQ@sH)p z^T!hP^;O=_UqbY{KZf`7cMxsikNy4p3q&XQV}C#YDbZwq?CtyKhrDnI}qI}$9FW*nv!?1MCs?RSLEkQbb)FfeOY-$ z{t==DmAe~P=AR>aX3PVPALS=^3;Vl3&1-n9@#FkMM32_rQT`-f>7H(D@X=rLhZ3FC zKCk>){v4vW8y1y6&%a8vc+69cU*?w+Jyf})oGDzp8daKKYRnbRD5ufel{XrTg{UEo z+J>)e>{6IObX4W@#%_iCh@QQ(9mA^lxY22uclswTw}WT^>de%D}{QZ3v&aS`WAXrqqt!}VRfRxxe-lk6oyx$ z_qz@*tVMKcZhX^_!t`piX15WA&4^~?rZ$Z%Y+a378^;uOA-XKLVbj>czSU^^##EVqM5nfnx+(Ps741iZCLm%(c#0FW;Q9@O?1o9rI}3&4-u`` zZ)s*m;c=ob{m9J<&k;S+cWLH(1xK{5ANe1JH;KORBfno*PV}%JIkWHy(GA(fnavBC zrgS~m8nZOBWucC!_t?dmtqNtLU$kA5-MY|5bnt+snQaRFs?m(xwuK?pXjpl>!iLpo zUU~b%{?%w!!w!WjtI_$Z?O3?G8a=n#PK7s!?i;c+vvc9SYBa2@3tgJidEPf1$-3t3xqt_aCFPu@0)@a+`+@j2Ne2Mqj}{63!7A(M$Os7q+TKW6Me5$JOYL^6`cF)o90t6AE41(!H;O^-&doT@@ExZuSR3b=M?IDrZw*!e@FSe!ba6-$A-Cu%c{|z8!jk3 zSdE4^{GHx6)#%cuD+?!AqidV4DO^yEZf&}@ za7{J3z3KYGylQlR(@ll@s?p<3w-lBVUD&)dGq3PgH5yj_O`%h-bY>Se&ny47Fti%Y zYPhX1gJ^d@&-sO|s?qwnI|@5hqw|{XDjZymE^WG}a7s10wrNq}(rWZd(}RUySED~S zJyLj(Xji|7#|n>EqxEx-7oM+1=QTZ9c()o|+Vo5z*E^l{t^=-ZdcM%38oko=VqthS z`g7AOh3P~S2Q1F~p|C|Y8e4w7a7;D2qx`4B{A#pg!<&WfebPNN`I-H>Fu59?*R-r~ zLN&UyX?bCBHM+KGMWLaR=G{H|!={f4qpH#8O`jCDuSSLDzZB*Y%^Yz@`Ln{uL?;cX zZ~mg7`leM*8qm=EWudMbwKl6_nP`(f_05@LpK8?5oGlKkMy<`c;zXj=8$N8x7dNa% zpEnhYTUDb%b5z`oXz#XRxND)Hx@0?_I>M{n~O`TQA2Y}@sHK0wYf(zyL!5ZxsxX3 zT8rJP(V*t`Vx=05ZthbYU5zF+_bX1XM(Z{YC~jYkUTPdvJfs?J);zR$Cef4uTQ`p= zUQ>;BZXQ#-w;Ju;Jiho!H9E9;Qt>mQ4;qu^DaDoj(|P`G^r_9$ih~BE(c7agY~G-_ zUNyS9d6VLn)##??&5FAdZ9L|@rtcSTs79AIZBblFG_3FZ=2^up*GOv)>w8c0_Qh!6 zs%S~GE-tJ_FE#H`JYZ0|?R$f^Zr;0ibv1gsdB5U2MAHV%Z$79vd2l*%+Mp%PhZnCU zdOUTA6M);B(3sZ?}p~2IFe}e=#QIc7uO=% zf8=M)#}{Lw(t_iYBa2TT5;2AG_QPm@!)DStKp2|&xv}E9a}!LxTG50Q9ira zWq4Y%_t+g9&ModrwAPqm<@1ZjRik<3xy4^pqgf3nL~Hh&nY*UgZB$z2-tjYYzbICShOS-T z^2_3wYShqjU2#S=YHhi`xF69amHL(&ib*wUXt}X?aW!gfxv99Yn%Af0SH+jA(SVkl zi=9WOdmp;?(3V?@8xUPm>C^J-;t#9QfRl9ts~cKxubYeHQKc0uHwRKG^^#F zV)wD>9&R1EOUt6-QADr$55YcIOsdhoEe{tLSEEB)9xe75m*%}%`Eko*#cQk4$t_EY z@tUi)ozwC}@wsYrQOlFXxof4{ZfskcdAg`3q|tJJ4gE}U*u*qyZf_npb|IcvLl-)$se`B}DUE=apY9-c^leHM~@G5?!FKt-rG6 z<>IJ~(ml+p|7FW7#ixia@zHC=xf`e3?yTRe`Hf;_lQg<(jays(T->-;zFVY{U_x9Qv4&)jegtb#ZQRt>$N!Z zWpTpHv}V+ALM~I9O*DJ_gj~LK7SUl%6LO`}Jfex^rI}8pdx`e+*TS7k&l8mBtbM+P`+{UfPmq+#2(n%cWz84(ob*OH1h? zqL2E|Z|+r^uti$4GGKmlztXRWe(h&Hu(W{a&V~uOA*IKOb{aV$H@x%;(aVDuwTvp& zZ<$v4aJ5A(V@v&rp08ijGQPA9(KD4rEfY)I5dE(2qL#^}n~5%-w5Vlj>9(!XDt&$4 z^wLvAuZ~&NvVQ5%S?RX3+ZMHKRQf5=Dg73;Y+71IbZwtSE#E6eTc;z}9JQ!rW~o2X zRSk<;wk-A7Cf)YE{)<|+F0CZ`c+9-=cBPfurrTU+|Jtxr**=Y)ZC%u|L+J>j!$&S^ z*}3!-QFnhtyOt_Dq$7{=E3rrE7^2R8?|YV(6YaXjqLzJ1{dP=8){Sjw-mf%+sK;8r zYuUdvm#EL^=JJ81ONo}&k1Zcmx{IjIM~9SNAv&{tSoyHhN}?r|vE{={yYH0lVP(VQ z@{y%Wi0qJI<)cd<5{>CMwtP&f_s;3a>&K5R|G2af(HH&|UsAe(Xq7}Pwcv?bBp=3(WNN{0}Y2Rz+!a_LQ??FOvdd|FBElIHDPdAa4x(&TD1 zxqNo%2%`Nee`-0m^g7XL{(YMZN{8&4=6%q-vhio7%5G`2#;C2EFDtD_RA_j=<;v1I zM3491y7?ESPl%=t|Gee;(zrcC-ubGzp`pibN^>Q;KyBYRxX0Zk!?x`k$Mv|cG-}^; z54SYMJsv6@Ky-<@LN)q*kIvB_s?i^M zbcvP`t)F|dM}72BHCo=ITl8f$npa*eDkbS2*3b26EJxj{(I-6`qLym(WskHTn7?zE=qQMYPzS?jt{3z5lQ({;V5Pc`~^>-y2a zYII}k2GK~O!MS;@8%7hV(Zbe^qiNOXFiv2Ca5%4$^K zwsUlyMB$q;4#?|hG22|frncRqedow3`R^p@)Aobt^lCJ)?T68IL>I0-vTe_39??}k z+B;e#QTXPMiEaBvl@mgh^qW6^6s=DbzVUOvXm_IU%^&+kcS)4KcfVisvP5BKaoc{; z`x0HCqP5m<+duj;-KI=)+;%|Jcw*SYNowAx8EpqfyAs_w>Yj!JqkXH<_uCGN5~3|f z?o&P_IjFuA(UHy>q(NUk1 z(z#5^9qFS9)#$*sqoWO~(GhLOL^_RR<$oLZ6`+CoRS{V)+1)OofJJzGm`)Rn)Y$m)fp~Za6EAn!4WHc5T%EoHW|K@r$;bqMs7|wQHxI^P>6ZrQ4p$ zEX~Z19wnO5_p@El;=kytR7X8`b|L)SMITF(~eNf7C?u z!1$h-`=eeGofPdcIz)rgNL@K*13&TrqCZwP>-j)*9FZQgRnLc_bBMO9?9lV!=rN*8 z`~0xyqtWX`H;y^5=VMWPLD>6A>Z#S*doGDqUYJG?wjSxDaTkYZw))A4b9+7!T|;#L zh>LnY8Qn^>Q-9O*spv(b4g3G1=hM+KKa(o{_=@V8=uL?(P8Y$;jyaq0jVm(H%q&Npv64cSlIWD7VcYMCemwL|AH62goaoU> zD}3}e(eso3;-eKrua6GVUx{Y>)pb$s@-UY<>htkm`fZI8U7$8<&bPlBwVSZ*e06){ z==L|GGbB1!&8{Ec{#JA|(I1Ac-TvpO@rrch4;$8Re>*zh$`GBaPHfw{eR=dtqR$5E z_7&0H5}mKE9rJ_s52Fty3O^kOw|^Y*|h@=%na4 ze|+_I#}oZw?Vg$Xy3>flG1S+cmqz}qThd-%cZEdfs-O3Nsl8j>4H8`tZC-h^y<9gh zja0e3!beY~BW3T6b^dTvqAsXR9QK#?#=0)QlzYX`rKsBL8YP;ere*GFXs_!{bdkEJ zp;z6YnvuQhMzQT0by3gWQZpytXO*w(L-auA^@b(weXG$c4PAPzUX7k^sPr0GH%Y3b zkC=n%HmKRdpt|p|hdF9V`=GjgiH2sDv=6B}jcD`ilJ;SB*AkU;OWH@&JwSAnk4Dw~ zp6EUwjj8*b=np;`SJ&sdaO`tbCcmV8eBA`1ijOAL%_N%Xqe*pp6E*m`Os<_e5^d-Aw_e?HqN{wgL0#ST>E8X{;Ar2dZXD73KH8*iGotQ( z?=$L-AR6hT@6}yQw5H$t_v`K_s`KlzdEH-$`uV+YS=aA|bPriyb5`A!M0@*ao4WBg zrrU1K4eYg@zgIFU85Y|83QA?wuYH?SNBB0c9`bEL&FrI8QGGH(sglyZCsJ4YHlyAi z9>yOzD2xwd@-n8N9_kmizS$VI&iDN}shpR}1$Cm&Evlz|`}JzIg@&D|V z3};$S9VPqyYHf4OB<+X>M5mELoFe{6A+6|Gk%gufV_13Y5c7!Iu6} zJ*Uq0&qsMRqCPzGAMD$V()~lbQ{ONq-2VEQ(|k`+?c1k&_mX49`2^Cp1zA zm$UvN@3G;YhGRH zM;4?`QRc;PR8f`_^hw44hS<0kGe&5ufGx~spI@!BK4eq9#4-hqr&*CY|Y8YyxPd0 zQ3V-Ulw--L+lPcP>6}N@ckiB;^X=>KD+h*S`L^qX^h#juL0LcM_95Zl4J_!L z^>ZFkpY^L6&g6`mHYV)xGC$j#`oyn!Mva&CpWxe7TgUi&Jg1iW`!25@^le7&i|=+l zlg{?rX87OT|Bqb}rDsn19Qs|Zg8p@mF`fDU-s`BZR{sBVC|Hk_N z*w0<@4xy@_w}#v=v4bC@y~e|x^|ua)m9+APygSz zGWqKKOs}8+{^#_6&3otH@0Qp81pWK=zgOVDz5@5k>-6+J#y*ws3MD*xeEprnuO3gn zzI8qSJJ3}lzsgV!-)LOtZ)ep1R8M;L*6#hkyC?jXXVqs;zRT@! zZ)WA*%<&nOf2`;4J+IPr_|Mz#QuEs?Eb+gU%PQv|?K7(Ob{LvURBZOohGY{7+W+-QrCDTg8m}djH?<=_PBvDmRSj*z;StmUzWKYvZyG`{kGeG>yh*Colotv`M2Hvwx8m^cVGXN z@q5d;J=ULxIn_&kyZw*#)E-OvX^@iq9xAk1`Fu!5e&?Q*PmiqnEXRL7=D%`W;WG(Y zxf0IFm2jRb;cx2?*Xrrqa_U}rYz!m)Bcxnohfh3aRN8(u^6N+R&Hi-jw~q-&msNvh zOeyw{!A{k(Wn|!neG3UiC_OD|vjl(Z9OSs>r{-&Z&ZYZux0>tvyaY zcYT;YWAo~AS*0`O5$P}fG1i~Geq>&*!3ydz{~4`SkB0}A!`3JK*_&6#^a}my<5gOJ zfqzArSG)LX{`aB2LQ#eKC8;0D_&T-JKX!GZX1d2P!<@{}e~Q3o6;!BGlsYA;5J`nP zna`@+biW(bL`bRzW?vP9jay3F@7a; zDty)}uhy+TntgpH;gfrL`Q)BoLw{9PRN;?qr1xD$HTjj#s`F*V!aFcuKa2kJt>II7 z{@UB0A^x$~-_EOv{&qn<>}OR}Q|0++XE`4R`RmWD>}%D?j_>w-+wUFHcWl1xPTp_IKA0PZDJmq%Bp4zRow>ys9z7XnM?%SM7e4AH~`!=I$v%+hu437-y z+|oAf|8AcP4HbU3TG-d4^0@SZzrE_pzjoag${arM$5l{U`H@9+ci%8F_K%8LwXSb- zYAgA9Kh8g<6;v2sRAGEcjgd!^?>_Ro?DxBjndJYKO;(MRSFB&nf7Q|bp*74uv|07E zzt>j1zmc8;Z~5n=EdS={t={2tFMG&4nc-17EB_)Urw*0%e@z~HLz`8_nk#|L2ZWw( zl690kvZYtl>B!o>yeR9F_NQ0cX`7A-xfzwtXMtbgoO;WT&#UEY{dSLxpsmb1GEGu;%G{{zdNna8KpcR`U8K>?^CDs#)`NOxT+K z=Rc<{_vyEtu~++QMLEMua)w80kW~BXcrz-!eX;y>%=AZ^&T7?uznVi%o$8<0@+yqX zsPyR4YrpV0@Kw*e|GxcgYqI%(PX4~=?63B^_&24>{{I%$ta_+6)v9{?wx8;$)==$g zi0Y+A_?|JUj~cHkYLeeFMfF$H{io+18Jtn8`L^hD9vR$84fbt!|B1WMPLy_Qu1 zQ`&8%-9_5Hq&-mDBYoSXPW5e1|8I~(&5NYH#kYOc^K$!D-*#7@%IzzpJwr_xZPYE9 z-*>%Q%~d1TD5^_T|2{o4bEQ4QkLi;+L)AA8@a>uX2V^c$XO~C#t!I}f`gX>s^?d&$ zgSYT)r`5OfZJ)J6yOY0tOXk?|`}_93(Wm-WRW9=VKj;_wuNZl~zrAb!Eqt}VR_@I_ z;YTj^Z8!f-S#y2Qvze=9?>9($wT!tz`k&8)V|>-O-Bq~VUA>iQYT7XSerB_gTV=!T zUF7yba{D;BeSY@S%%ZhT_8#9})L zp7moEN`L5CKUbGsIAn%zkM-Y5H&2dwp{&3{xxGkcxLDe!q(8LB$2U(_*_5R zqN>Xj%eVU5#q#aB1@i3+FRHnt9?!j~wr_aBw?l@z+^7B<6yC|TW;bg7Yp!qh{!#h- zYCgBkw`UeAzJ1BJ_xLfZ=by+d_utv|MCL>PJ;$wn%)0qj8Ph6bS~J~?m3&{{e}Hd8 zZeQu|OaI2c=ZTD#3RC>|CO?t+)Yo|;vq*Y=?!V*fiAJl}q;T?ZFw4eFgAC|(b7R$_+W)}E5Mb#xU!T;Ow zMn8V*s6BJj;Ju^%nJ-7pj)rBrtR31vwVoAioSir3!P3m^@aCc#mpQiWh1@vF8kc#n zzE5Uc=EUJ9nk={1lm5xR&P&k@sWT%Ja@UjkTT1;Mq<=lX0!6jEZ}WBAW^WkT$+sU) z8c?^M)Y&$>?U=E3`^xx3Wc)EQpKY@z4!^eU7@5@xvad6w@?2>z$^51DrMj!7<_%K+ zhD^S1sncZHI#0GPl=}0ef1&g*l#%nK9VhK%X~VrUtoT`@{FtyBW7aNq?xGsigwB!LMy=!99n^H+YPF$n_mrM}rRMf0C9>ArR9Oh31UnX`RsGjn~rDC7SwhsCU_UyZ1w%M=_Z)U@qyd$@lXTzGT%!U>CBs;TnSM^zTg>O~v z3*Y8*bzQnD{}n4;A~iWT)6aiKE*#hQbK$tQ%!T9HCKrxthuoRI)wy0>3Tk(MyI!4| z-`m%`CBKJnU-E51`S14iTYu9zjJ)5sUDc2J^zBksh1J7+UR`}y7ynPe{PzV+?;M<@ zhE@D!iGObVv{$e}WqSvEs=ngtYKS;gO%cbclf<>vTyb4>g}A9&ByOQHeM0{Bs;ju0 zY8CgvgVn%3>-z1d`~3$yd3PfYu8PlBNB5aSe=zh1&-eev?<)F(p+6YL-$#Ei^asyZ z5BGUZT;6A0{|>}es-?0U&Ov{n=bz_y>a(u*dY_lzYu+$EmrGq8*Tvm%4xZrsna?-g zQ0FT8Epf1)+P%+x^e^(3{eB(WU!(tZ`uzo;)Cq?9<-B1&3H@`>qQ5Mb@fBR%8}fo> zwRfL&iGv~5-Z0PI=nsbe+1_yf7`9(Qe3duU3x;u)?f0<#KDGzLcE|Qt+3qjFWPdrY z9FI4QtI)qX{lU;5)4wkL!O*YiPtf31-Y{=Vd>^qRe$Cs@=ld6%={&q)e1$khjR_jG z=+OVeigdj(MvVy?wCJ$nf9nv|Ut@v>Ejsikb-F){QDcGzEjsi+ zMw}2EwCK>ET?_iw^w@JgHN`7&Rtn@Ct9(pC!JB*b%=<>|X$;^YhC5 zi2D-9#M8WGf8GbfI8A?ov*|a)R}fp`dx#yef1#Ql55}l5L4y_@`X}`?A7h;6mB&@$ zU5FFn+1@sFKp#VV1=}t0J;aXKzmQJ%gE4AM(4a+!{zZ72k1=XY(4a+!{snxRk1=XY z(4a+!{>6Wqk1=XY(4a+!{*434k1;`u4l8}raT*O;bm*V7()nPF8Vy=ht1}*B)M(J6 zL;prcI&Tc$VQ{@aZxb}=Pz_)_YE00eMTh>amh^ZqMvVy?wCGR`Bp+kcn4m$64%Hy? zF-DEMd&AFdLTu2YL;n_#97o<8##e}A)R>?_iw^zUPU(7JjO%$rT}_;zL5mL6Q0ice z8h7`~aS@;3m7iwV@%MZ!^%YFfe9LPSee8))R>?}wKn_57&Rtn z(V?15eN51xL$wa|QDcG@{ae84`G+wkXwafVHI;c_g5kHs;rz4cuo9>J8WXhWuriIh zn4m?6{%z}Y-Wa3C1PxkrsMaMPW7L?SL5mL6dgNn_8WS{V(V<$Oe2h_Jf(9)*tZb0x z$EeYuMTcrb_J=WQOwgc1wGnw3qs9a+I#e5zhcRkQ(4a-N33(V}f(9)*RGX5AF=|ZE zphbsj2KgAH#sm#IRGX298WS{V(V_Ytc^IR{1PxkrsQ!a|j8S8P1}!?Qd_T>PQDcGz zEjm;)*)M8L(4a+!YIF978WS{V(P3o^@=#-f1}!>NTQX0KQDcGzEjp}h#ePv^f(9+B zS?mvE)R>?_hiYr`Fh-3D8no!JvJLY@jR_jG=umCTelbRk2^zHMP;Ey(#;7qtgAV?_iw@O}NyOWPGYE00eMTcq+ z@-aq@2^w@*`9Yee(V#{3!*qL$30ibm*^@eG(4pFk`C*J26EtX1?alsCqd|*mAL^q< zgBBgCeW{BH8noz8{fP0XF+q!JKjwiN6Ex^h?N41y(4a+i0Ch1&jR{&*2QnUG)M(J5 zI*56oMuQgB!Hh$V2^zHMP#wa2Fh-3D8noz89ZEjNs4+o<79Fa?$j2BpCTP&2Lv=X$ z7^6mm79CcONb_RUn4m$64%LzD4>cxe(4s?i6#GMs2^zHMP#w+wFvbK8T2#j{9%Iy) zph1fc)v@Gbj2aU(XwjkiG5M%5L4yv}ag4_pH701#qC=ICk1=XY(4a+!{tq~$&(|2E zMuQd|s^iJS7&Rtn(4qgOZ@NE>QDcG@9jX(^!x%LhwCGTsNFK(hF+qbC9r{0hkg+T>MvZ%Ur-%2ci7h%* z=dfRlQDcGzE&7kSBHmhcRkQ z(4a+!{zDt;bbtOBZ+KrWxir<(80Ke*U5(*=-pXa^c#Q@vs>{>uF=|ZEphbtuqSpGT8WS|=P~F0K)R>?_iOwYN38Bm#|6Xrp^5c_X@62<$TP&Y#*mjhl#aKk9!}#JH5znSd4zG8phfj)y1jxi zYE00eMYWhb)R>?_i|R4PV~iRNT6Cy>$NW*FL5mL6683{JYE00eMTh=lzUldjF=|ZE zphbu33Gy*UjR_jG=uj;sA7j**ph1fc)sy67j2aU(XwjknfOC5M7^B7n4O(=lo+ck- z)R>?_iw?t=tIOjB#;7qtgBBgCXUWGHH701#qC@}D?sWecqs9acT6F0DK~0*EF=|ZE zqIw}6AEQQt4lBQB9BNF^phbt37sNWB)MvVy?wCK?PvzYY! z!WcCsXwafV^+)nCMvVy?wCGU%iF}MvV}b@PI#h3vk1=XY(4a+!>P_-7MvVy?wCGU1 zMLx!;(XHD4=X9J#gAOZivmFh(RlbvMSIfRKJik|n;~K;ByCF6oq~mRkay>z;R#49y z^5T_goS;SZVcH*~MuQG3AEo0oCTLN8OdU+npu@^1)Ioz59r}M{n4Z5Fqs9a+s=qKE zH701$VdbyP19$g^fA^3O8?>lCOUK8kF+qbC)#uCyV@%MZ!^#)bM~wz8sxKLb8WXhW zF#Jngd7i`=6SU}1Wf+ed4O&!L#-YXpEvg*jFh-3D8noz8<;lkwH701#qQmfqC*=4s zMvVy?wCGS3$;TKqCTP&2LscRlH701#p^6xfF=|ZEphbt3x->sVjR_jGs5-Gfj8S8P z1}!>No!K91Owgi3|Ia4V`vYUtn4m$64plw*7^B7n4O(B@Xjqd|)fRX6sBF=|ZE zphbtOJNX!+#sm#obf{J%A2lXu(4s?CCJ$rOXwafV)j%G`n4m$64pk$0s4+o<79FZ4 z@-Rk?2^zHMP&Jc}8WS{V(V=Qd^J3JPph1fcD?QS@{@ym#w-OU;Owgc3hpLtN;(Ffj z@AEWqf(9)*RBhyAj2aU(XmOEO{#_sO>%^)j^)N<_2^zHMu+pC9$EeYuMTe>v`@NYmkpI zYE00eMTeDvX}(5-4%MJ^dyENMRD;<+YBcCpZ687%)R>?_iw@OL@-RVz4%INmp+j4?rj7S%}Rff@~3bXXb1cudfuLp7Rls4+o<7S$NWqs9acI;@Ok zJSJ$+q5paYxh{z@L4y_@sx{N`6^v11f(9)*RO89V7&Rtn(4s@N7Wo*X#sm#obf_kf zk1=XY(4a##kv!Cxph1fc)g%nLLb9V}cePs&&%57&Rtn z(4s>%h5ccS8WS{V(V?13KE|lgphbt3nE9f{1PxkrsHU+$j8S8P1}!>N)5*seH701# zp<0(bj8S8P1}!?QtjF=9#sm#oRO_=pj8S8P1|6ym$io;lCTP&2L$x9K7^B7n4LVdC zk%uvAOwgi3wJ~`Zqeg=k9jZ;p!x%LtXwaeBlst@4V}b@PI#e^r#~3vxXwafVwHf&s zqeg=k9ag@V=EbNnL4y_@s{der7^B7n4O(=lzE3{Jn4m$6Y9`|`MvVy?wCGT6&T*o~ z1PxkrSlNO+)R>?_iw@P6%ojB#XwaeBit!ku#sm#obf{*Lk1=XY(4a+!YHRW_MvVy? zwCGT6Lp~NJClzwYE00eMTbh0j~WeHbf|V=JjSRoL4y_@s$I#)7&Rtn(4s@N8~GTc#sm%e zFXl?$-}*snTw^#7G;va+yg&cLbbNvq)t=NtjRqa6z1SZnXwjkCn|c_d#sn=otn5R5 z)R>?}hiYH;gBlYwXi@!$@u)FDgBBgC{m8=@H701#qC>Sm`52?d1Pxkrs16_>W7L?S zL5mL6f#hS18WS{V(V;qse2h_Jf(9)*R0orfF=|ZEphbu35b`lbjR_jG=ujO>KE|jq zL4y_@s>8^~7&Rtn(4s?iIQbZ(#sm#obf}IXA7f0=qB@dssL`N9brj<;MvVy?bf}I_ z^J3JPph1V~81{oPCTP&2!^*Mj2NN`C(V_Y=`@tABCTP&2LvFM^M zJWrpI#tB+fXQurzYBcDua#lJ{V}cgd+0?-V4LYoxLmf0|(V;pw9aq5^H700LoyU08 zn4m?6mGhYgCTP&2nwyS`QDcGzEvla|4~#KEiw-LnP#-lKw5Tp*9BNF^qC@pl=7BLL zXwjj%i1Db=pha~t<4|LQ7S+!fhcRkQ(4a+!>JsuXMvVy?wCGS>N)_s4+o<79CcuNb_UVn4m$6>Pq&9F=|ZEphbu3D)xsO z6SU}1UCsV5MvVy?wCGS>Lq5i+F+qbC9aetMd{Lu8iw@N<*dNBIF+qbC9ja@|#~3vx zXwafV^-J%CKL4y_@ zs+-71jR_jG=urJC&5KcEf(9)*tlUftqy3UxOL5mL6W9j$`#;7qtgBBgC-;s|oYE00e zMTcq$`52?d1Pxkr=)ZD5{rSciH701#VfZ6|^0`vfn4m$64%JffFh-3AEjmqu z2^zHM(Ek~jbbT;JjR_jG=uo{tKE|jqL4yv}@5#d$H701#qQlCI94~52(4a;268pm# zH701#p?aA-j8S8P1}!>NuaJ*1YE00eL;oi)()$Ht)R>?}hyIWIq?_iw@O$G7gk zBb&xGhWArrVqIf+Kh+J&%J*ET!KulRRD%{Bs-fxj3dU&Aq8?5?bf`vW;~iPX>3uAr9Q@(ph4>m>k>RYd_H$g>Y~QP8~#3s9iM8}B1VU5LfT)!7&Rtn z(4s>%k$j9%V}b@PI#iR$#~3vxXwYJ$ifVM1o7Bshm6=i5HM5hlQ?k>uJ7kZ_UYxxy zyLE1_+=01Mb7$q|=B~=!n7cpsX72r5=X`U1P<~8)t^6kWt@69(kI0{pKP!Jx{^$7{ z^S9>j$Um50l7B7#ZvLbE=lQOMhC*whQrNL@SmE}<{e>qAD+*r}qGI=AbFo)(U~zPD zVsUzLlj6+ck;UVRw-oOz-dB92_;_(pXj_6*Ht?0+3BQC*LQlL(>tAp_`kE{|K_90sm^MO|C1!cGG+Bdu0=ha>!FtCTGixy zn>sw-Qyr6US9=$FsY43ARcq8oZ5dV6^t%4)jn0GAdz}ZX<(-G94>}K5ANoJY{$=M; zD$`}Os_QaFb?Kti_eYQDrPM23$M`ntJKndq4_Vu{3wnq4i3w9f|CG?)GBLDw%k3j3 ztmkiEwoYg_oEX|4ObPAP(*KC`A1wXnN&g-bHuhUjUnjKVCWdy4DWN@A`frndE&azy z|9;XxSNhkH{vD+MV(GWiznAo%BK-@7gt^V{9ooYOhjyc0(laE?>bBmYJ#27jH|!PK zGo=4?>EA^9drJRpL&B_n+dH&}4i4=Gy+V7s^q(gE8%uwi^xq`?H%kA$(!Y-MA20p0 zr9YPb)ug}U3XiD&<~r{@EzG=jwWm%GJ?Bme?d4NL`;qk2-U}bba&$HK#h<2yo`I7> zyTR1ZUOGLrwfE5&8B=>tm1WHCl68<|JuE$s%RO6?dvhnrx>@#hm-I}LG3Q9uWs>!w z^wi$(e~80M*53P1P76JalR`UoYG_ZJ9@^ToqK}NJJvY9To|%%hvt<2NvhI{~W92%b z!lp9jQaMAepAve8%9vv%>vYNbgY?v%X>ZF6YtOeArA{x&nk-r8OIGc9I7r6So{f== z*-o)~_V%PU%@k#+)r# zmrB-3>8btc>rx-i&)T2CVO>Jc;{!u`TzhCocMk3RnxD*?Aseez#+$>{$AX!b)^IWH} zui8h0PW9m|s(nlt(k1l#ZeVDSZ4d3p&Y`V+taw?*)IMVTUdCK0S+Qj0q-SS&#Mr5K zsBnd3^^(VpcV*14WXv9t)hIpBb_yeFAAd43!`erozEbCY$vQ-`){r{2k4{g^nA*pv zr84Fs$y!UY{v!LDB{SSgX82RdYLprNQO5jSvUZTH&eHQc$+||eu9mD%Bx^Ux8rLh# zzlZcJl&pOutC!R{QnHSati>{BEy;=`>tPvlj%1CHtoLPKn@QFT$vRuII!o4zGUigr z+Cs9j(sRFLecNO5*>!*Wk@@y<;q2}B_(KC7mWUVsG8a6JR znSbMXYR%B!@!4vn^q;X-*m~vU(0(e9jr)xaxBtd-nbhz2jJC1V>G-@hX?&<-rT(*0 zf2uqSTrBnf#IO*OmGYOMNHxHA#Zt9iQ3Hmiir^ z-}jgL-DEv`$vPh*^_NKf>C(Tm^xrN0by7c)bsi<_e7UUuThjl&^nWS+Ka}-Qti|LlBil>; zj@KkJr2g-v{##OirqsVd>UX?inJ@J_Ubp;I>UX?)IYR39kab=|*7;be|D4p{RQeB; z{)eQ$EcM%DohQmVUn}eXf%N}H`XgE417!V|%6<=$`6n{}XJzY_Qhx`jf4tOxUh1zd zb%sivO{D%KQopm*=`2@YL!`oNssFb0&yo5UN&V%rbq}e3iqyYJ>NiWBt)>3fQvWci z|B>{^QfDKnKT9gyDD?-)`rIbfa{&-CFAJA@g4-d!HzEI$pb;Ru`U^J6^$F7lrqz(v@aAx z+wm&*)VlDz-0@oXmr>~Nc*VP*6#Cy=Bea+G4DFF}b^3fU-0pZiyrb0bcvZZw)bDt0 ze2mn;QR?p?^(RaH8>N27E9Z*T?|A(jOZ|>l(Nm@V5UD>(>VGK}W=s8Xa%J96uFMaU zEA!W+&Z~pM(cLFk<0r`Vc~1H#$yNGWQs)MlXM@yVHXw{&(H7b#rT$h@zvDIhDRtpF ztK*gYwNdErcs)PA6#Cy?Bea+H4DAte-S}KF-0pa_zk}58c+J0$)bDuZf3(!ULF#WW z_1BjAH%R@CuM7G}{f@5=rb_*euMwt5{lQXyq}2aHDkM^WtXwnq?H!KgP`PIQgVb?@ zLeIVJp`9aF&ROZ7DA&(#N}cOvo@J^3&VVrTgSOBvmHJys{f@6tj+3n&U#DCy^*g>= z`IXfFqs)JUVtA(=7En zzV;a|^*g=-T2ty*q<$}{|DjYkT;I|L88tBU zKO=RnlzG-k|7)`TZ^`;UEcG{)`W;_?9VS~lz6!fQ_S^Bb*rihcMVbHUvfmwLzYj?L zj<3`P>Nd1eYe#h5zd8yy=m0f?S-|_X`Fsa{F>NiUL zccj98QopaPe>YkG?PUEQmpZ>26xR7>S^qs{{g+Ff-UCDbV^ZfLnWvKe7i9gNtiP4| z)203;QvYXC|2?VyBiXv$;8161uh4dr{u5-sw@Uq8WxtKG-?>u%NtyqVvfnLbzjsRg z1El``QvW`wGePQ)kosRr9V>OdFZEBB`U9l@O{ufB)IUY)$5OvV`p=d6>q-4}rT$4$ z|F2RfEA^k0I&-CdPwC%8>VGPA7D}DfrG7!`cb5LwWQKc4{T@>Pb6JO(QvX4zvv5#Y znO{i#ou&R8Qm0AkFOoXvNd1qc&J(i!&&vAWCiN#u|LsG*=j$x!9oiA~p*_1Uv{B#9 zeS7b*o)#+C z&UWhb&~xsj&|W?@v>!=N?NNLf%ie2_&%jBc-C$~HFP$FR+G89eV``7IEMs<; ztb-)$Vd;5XR;DDYv6E!oEc?1kdZx&jb0q6B$@)-wYS*nxeVBRe>J94>dLADb+T+?o zJGygd=hv)i=lU?`+O-|pCG;#A7}_7Vhjvuw(AKW`D>9~bc4p}>^vvsy&mUz);sUoKa1RMaizFpY-w)npHiv)GdkWy z{_M4WQa^Q#D`_2LFVBrF=@wi2CwP&}sr_@TD|2p@RrkoMN~wGctXdX=k&e>a0L{(RQ{hF%cFmCw^+C!Q1A-j98^);HG=<47&$>pQr!V&u6;--PPag5P$NMd;4>l zaXq=iKX7qeKU<#BEpy`W#4fSlWW*--|J)Ao=eG9q`v0B+?K0zf^0VNKi{pA}R_q6t z#x{`W$fS&TocyGCQ1X+X7egdJ`Ki%I^1qe*UnKup$uF1ugPV)cbi7ENX&&|gqKl$l-o8+IC{QB~&87cW6OMbRIO|F-E zxzrWOuaSCM=lI&5k*Cb}Qr{!bnm?s}lRR10%6V^={JZ4(H^}|zCi%(F?o7$~k0-d~ zBtOS3x&AqlzeMtVxz0k#|Bt7<ci#!SIT)u z$n_V>^>2~;-&^vNcP5=BCwYI;R&tVeDG9m$7bJhRB|mxRbHC)DCHJ|7+~*0B|Fz`zmiic}eOMXkaKPx1Ei{!VF>nxW1wkmih^)|0(sxlK-fjcc+~9D>?6vQXe4sE9ATbCBHzf|9v@o zw&W!5d!KOQ=W_Dy_cbT3C+~sZv*Y^jEn-VkVju8gzp-MIcgWM+__>_CPhRZA_2k|1 zN;|F}ZV~%JN^FtrY^$tzoV=ILllZ>JZZ_D^kw_bMgkILR&mwHFp;UAEk z*X26vN&cbc@%*D_$8MMW0g|7*|9{+#pIOOo0k1l7J^5YWT|2J-)*`kfCH8*VbAD~b z&q;nS$v;=}pOgIL_d!GQliv!@m;B^+ z!wV!olKlFT|Enb2CHYO|{wL)A-zfM0bIIA#GQQ7m%Kg7x?*H$S(>N`zZdF#u0XG{Kex&9(K z?{#wC)sla=OV`)K*@hx^1DcWL#Y=_{$-Ni zUGg83{8N&nN&a@pnJxJ#QomC2PfE`FlG9A`P09DAUM*KxAo*uW{%N@n{Um>**PPzXpBtKi~PSfe+yx00^T-i8w+a zJ9dqX@4sXo;}>#bduGSp);acNsedZO-XdoYdD!eT38>m-@=q@wL6%IJU4=>=mg}X&qnHipH_`wTkVT8vCTw zr%U}xsi#PNMeF#g-f0{=u2t;isj<_g{)E(fN&Rf8zajNTfvZ_)lpUY3ymY?QLvg%Da*9TJREOVZgRWHb@<5H>pbN``B zd?#!F^tX47EA>0ZX5_{`(Ji+2r=p3>sr}igkxD;Vl`pH_kyWeZvvDjZPUtOjO5_u= zq;p(JmpKz;)pS|)gH&pN(hkZM*8aSGD>QY%XOIFqX9JZ7>wLguH%(+ok-7c%v zNabU>!qakv*U73^i7%o!%D#>lEqq_S03Su!V2R#nKV)l$ikIWuKdiL5#%mD=CF zAdEkswZDTG1aal_wAhK~#J2Zi->voC^uzd*S^FEB9>kTcX|WHT6Wh*@t^Lj2BXeqh zf4`MEWwNTPtTLo>vz#kmzV9!|s>brIKP+?JlsUJ_ss>W|+KbOsyBm06{3)v46Iusx zWlLJ@gmYrs`mwcp#dk8NcE{Kyb6%2FU1XImm3-+KH#LqE7Rahp=^MYvoC=v!Agk(2 zWv3UNA;> zC951+RVj0xmQ@$Zsv~l)KCHc?Tey>CP z=}qp>&t=B-*Vb+<9c#GKjY%K zUYZsA!KJYcYh&Z)MV>w#<>|9no<~QeeoE@DJcGu_GibY< zcdT6hB)R^t<>&y(PkusO&>{Y0{>O7FGp;8;t&U0k$*lP3OP9u;l-_t(Mm+wHXPM+D zKhb(gPV#fD@&x=2$ev>>M*2;Nrmi)Wq`Zvh^=_dKf&)-bR z`H!ctu}KeT~#z$#>*Fx0Cx^D);|@)Q?ELM(V@m z{#VL*N67UT%JpxN``=sglXn-LB`0~0(N=PjcNz(~{ud;FwdB{A>wH-9llLLxB|mvL zGFbAH_as+I{w~QsAo=|y|8>bv-m$zZ`N{j1XC*&*_j14FpC$LXh1}-}lK-{j_m=t? zsedB%x{`mk+~;h$&#%h;|3m73OWlz>e7D^HFXX)U$n`%g*S}MaUMKlOB>xe~uaf*` zl9MhuS4#e;lJ857FT1bSk}yf~4@!NqC${PQH|Udg{#@*k1> zDyip5{xQkdB`tq!c$~Z+&XfG)UGbfgpS(AIQ1Z(qe~9E?D*5G-pS*KE zSMrnh&s`)xc^924`K=|to#fX@!X(M>AUpH(WM_V#?98hrXK%~+Pq$unq-8h=JEWaXUA@r`~i}myoZ0>jh|V`JNZ|gxSqV9f7g!dzqN=hNr}B* z_Kja#@i=+6KSc7A_xvLzKY8arUh-d;{K1mnN%CKp{N(q7CX%20c91Li$?piACBK#A zx0U>VCE;Po&yYQH)5h_iWt{Ape~_H-TgH_S&xxHZJ7-Pm*|LBBS#p-hb=H;qUz^7> z|2RAL3(3Du@{`|CCd$#|_mon}PkvjekoBogKUMONOG2UKx0d^VmfZh4<^ESm z&d!$com?sRe~jG!lakXeEv|nhIWNg|x>B!}`+q?0f2HL2mi*-RulwX^@>|$5a^B>3 zu@cGuR<3`#oHtL-`?2IFzo}g*`N{8VgCsxst?g#Xe@^myN&dN#|D5C}ztb6#pZsQb zzT_vr-(4X2k>uBx{9h&EF3E2y_dg-`|3w@d9Rc6u9p0}C4aQ!ua}%lB>zImuaTU!l5>sZPnG=UQvX?U21@?plHW!08%n)c z@-LJ8?vnqQwC(9qof3K`@llZ@- z)}&qR{b$GZqL#6r*m32eOFLmo{rGR=sc!r-7G3Zfj%Zndb+h8%28?JQdpvzPekPV( z_yOa@1##uc^mzP2`bMno_yz9nScN~gkDVfG$JPIi@s}6JIW6n{fKN)rlC?`^{*&@M z;r~0=E9d{roHIJbtG<(&hdcehS91Nizolx5%imI@@pl!OSYNUEdy5*Nrku|aRcVfz z(t^LoNLA8MQ(7t^wo(#QTBD|qD)L6L)pH&v)JpLv)O?gf! z=jeRYl;@QSEK!!?3(7JqRhDz~Mbt=KfiEd5u}oQoFDt7#x)3$x6=e-BQr1y<6*bPW z9$!;7;1XpczOGbaxv~l0P&Q+QvK8M{w&7A`JHExgf1q*ho%ps=h0B#)_>Qt0S15b% zU1cw>RI2ekWgo6m_T&4?0bH#d#1E81xJEgQYn3CoPC1GnD#vlXasoe6PT~gT6n?Cn z#*Io1exj(V#fskk1Kh4O#4nXbxI;<7 zuas2WsWic_m8MvwG{bL{=D15q!*BVYHP-lZrsH=?8{DI`#qX8&xL3)*ACye2RSn((eb!&pu;veV>StItdf%WIRKi zf_2oXSXZ5f_0;Liua6o(j~RHTI+IF6)ReQ-Vr-<&!n4&mn4-?*=sBqIbDD>Z)%n;& zEx~iuQjRu7{uVuT0X9?1@O*V4HdhyMv;}JX6c=MlbqSSLs41=0a!gk%@B(!ywo#Yi zh3ax_tFFLy>Pl>{uELAd)tI5K!4B#=%v9Io#p(vkQa9oyY9(f?o3Nw089S+4@ltgg z=BV4Tv$_Lw)t%Tyt-`MAF6^f6#_sAKyiDDTJ=AKvT-}E~)%|#ddH{Q=2k}bv5cXCN z<5lVr?4usVtJUM!S3QB(s3)sLk*;wK)z~)9`k+6^>BT z@eZ{Oj#S&?ooag=rDot=Y9@|Wv+!;;8^@@f@E$b>$Evw_ui6#Ison8DwFefeJ@J0E z7Z$0#@d33Dj#vBQgK9sVp!Uay)B!kA9f*&pgEUo{jPzc0Fynbh?^W{{&qsQ%n$Nfd z>AmVu#-*q!FRH_Efm%T2C8YPN!x_JfnzB$G!T1%V_o^cqzl!u;brim)j;68%>AmV0 z#^p%wRmU=}Kzgq_j`32Y_o{`sOf91FHqv|5@r>U=dapWx@w-UxRVOlj59z(?Bz#|; zjH}ft_<=eV*QnEQtvVgosWb3HbtbFUBfVEGX1oFEz3MF7sLrAC3DSGjx%jC%kIE*b z_p0-8vsyxB3(|YlQpTSny;og;U#MlcU0sM@s*5-m5ObDzzNHQ7dql zx)i@vmoaBI(tFkAxJO-q->WOB??rmAx{7f%(tFj_xKCX}AmVk{8g=_atP_Y>L$jAk>0Cr#v|%h{6pPF{V39V)$Mp(-GP6qJMn~Cg@38Lm~#^8 zz3Ohpr;y&O?!nXQUi??B#u{}WM-^=!V^!PFSVK+GwF78q2dS7y@6`^WtsSP~AiY;R zf}VDiijSHSXvZ0cNbl87U_v{IXK1Iej&>UBYBg9-;|s6U*K};4nRupVV?)iwvos$Y zX(67iB``&+gXd`VFjZ@SjkSi@L~DfSYAM)MOU3iFCfH1Cisx(1u({S8TWD#RrnSPB zS~|AU+F)y~Ev9Sj@d7Oa+i011p_YYhwQOBg+9ADH>x36+Ihdj4QtyEDUac!$taZmM ztp{GB^~7wg7k1QoV<)W-UaIxQ9IYQ~J0rbU>yKTu0oawlE0x}>4Z`l)V7yGrV`dMe z_iFjrQyYp`Xv46VR)AM(!?CwE0H&^j@tH z2WUljy*3^PY7_7VZ6XfRCgF|RWE`wb!6DjI%+sdfP1^4g@m_5ij?Qh4`~~5qE^W@JdEtU zwN3blwizdDTk%nC8&1)-<73(moT}}_$F(Y)rtM^ZJB-h0M{urol$p;WdvEPHKBt|)`PxZ*UOR;)+G%`2 zt3kS(LI2luT%eiwl4fI>=Hknmj|;UBU(ph{NULMe|B?Q$)x+1c2Dn6Ph_7pnuv|;Q zH?&k{Rv`UfYl2I)rudfD43}xm@og;)musz<`3}#pR~!iUz>tIYg6%n zHVuE#rsF|v2L7tel-oBD>0#OqJ9R_|MksSSKo^D^leyQ-;NFR9eAd`6C3JP zc$U5k8|l08Y<&-==zH-Ty&6;Xeb`vvk4^Lgc&>gBo9c(~JpC{>(~sc!`cZ7IAIBE@ z2~5*ZVoUuLw$e{yYrO{3b+(?$1-g!HbkkIo3z7b>+l<>G{a<$(Uxf63-DlhZ>Hm6& z7wZWsSxEoa>oCqn`oCU}aVMnz>kSy^AbW4UA?E6hsB}U4zn;Rl8`A&vRK}MfdvCo7 z$!}FApKwOiZ|)qF<f@O+1?m6#1jbX5{;yBOY5F95LZ6J&^(h>E66yc?RK`ys{a>GkPwUgE6eIm# zpMkUVnN(&Y{a-J}XY^TA<|6xleGcP!NdMR8;(UD`KCjQm61@ap&`YsYUw|*_Ww=0J zh%f1juuNZ!FY8NipHqpJ{6OE0 zYxF(T*CPF2--{pW)wo{Yhac(taf5yUKh_W8M*R?eq94Xe{RnG6Mf$&f6hG6C<7WK? zZqZNTR{a!yuAgS+Hl+XSHMm_@E&9K%;||@#uXG!C>Mnk*`&gxi_>G>hRAm>^|Mfb! zTd#-T=?$pwLHfVm5cleh@CQ8wtMyd;QE!6#^rrZe-VFEa&GBbF4G-w8@E1KD59)33 zSG_GB(%a*2dIlcWGx2vl3ySEM}gM^nYUxwlL;mnlTSs8uPK0 zQG%_FQcO1%-~~n*wlNmsg~lRmYb?fg#u99Al;cH41!fpa$?t&le`6V5Y%IqtV+CGf zti)_%6?QaMGqV%Y|BW@6W30o@#(K;(HeeTHBX%_^nb{5L|HdY~%-D=QjIDUNu?>3~ z+wlrx2lg^{;*~}f_BM90_9~?R8@uspV-NN<_Tn{0HTE<1;kCwo>~9>v>x_dqz&M20 z8;5bAaRhHLj^ZHWINoTSz`@2z9AccpJmWOpWYl23!7po+n++X@8m6r(w;=m}!^T?; z7YhuZ`fW)6H$uGKNZ<&g4&GtZ!;wY_ja0nbXo6#mrg)Fh496PH z@m?bh#~H2gJ|i6qjW&3{(Uvn7A^qQIkK>IDe9*|GJ^|_fMix#qvhiV~6HYR6@DU>y zCmUVyQKLIfF?!%*Mo-pGMf$(d3#S>q@d=|3PB;4ElSV(BVf1I_Q%L_e2H?}iKrA)} z;s1=mILpYx*+xFjF^1wZ#xR^~6tMPLWdCmr$LEX@INuma{duJS8>8?AV>Ff;WAH^| zEG{s{;Y&s#mKjC(vN0YP8WZppV-!|ssa-#&_F-mcTu>dQLGOqShq%#`}8GnX!W@8cKEl6iJ z7Bl`F>8QpM#@``b&M0U62h!z?3dYBfE@v#oKaFKnP9R;*SkCw)(&da5c*q9*$}TW8*#KB z(qYUL>~E&xb!HPBU^eCG^+<;?o8b*+b1H+74r8X_V6zntG1D>6Y=bwMZ86_$k2jkc z%pZ#EFwIQH!;lVRW-%^6c9>>14mUedxgF^+W)9;!kPc(!GQJb(FlJZ0%j}M$%^rBS z*%QZ@z3?8hH;y&?F#lep!XKC>T{LZri({jta#fDf1halAQ*qYolGOmi?kWai;S zGanx|hjMfh(qYVDIN2<~N6q0l#T>!W$B^GJm?QCVa}!<@KbXtZZfCg zXXbR=Y|g+f=1kmb7USpUEZk--VfN13Y_|Ag$L%+>g_xdsoI z>+lzIJsvbS;IHOJJY-hlZ{{XEY;MNi&8>LE+=hRc+wrKm1CN>&NbI?mWb z`iXS{>scqM)JLB9)+xq4knf{)nsHC$`)JiL?uC3GE&kDn(%aHqzK_WF(J~odjeH+1 zoAEWs6W?<2TFa->ANf97A>#qa6W>ZO9*BG&tvWc!s)sjP4REm45QkWeFwaWCo2*pk z=Of=os|gOZno_w1dE#5m@K&oi7FcO`o7IY=!;vSxm5w8&6Cbd8;drYzK4|s9 z307Zx$m)j^t^WA1H2^1B1Mv}S5KguR5GJwT9v2Rsl}4hT{{~2%K(> z#3!v$IKvu^Pg!GdrZpCyw#H$xRfzwyig1=S9%ow(*pRp$4Tx&8uYfZs<)>M4X znuhbO>G-@g152!#_<~i8rPeHb(VBw`thxA-H4n?I`S`L`f(xxue8pOTi>xwy)mn&) ztws2nwHTLJOYn889Luc=e8XCb71lC*(^`&8trhr|wGx+EtMF}WH7>W-;5*hjTw$%p zcdZS$(%OjcS(UiT+Jx_0n{lXjS2QYZrcG?Zyq(9{kwa ziyN(K{KVRamDYaz)H;Bhtb_QOb%;;JW~5VDhZ%20I+b;V@iwGWSx0fZb)3qVNT;$+ zF#ZbZRMttxUn8B$I)&d@r*W56gWp=JN2juM{LV6Qk7awR@;%b2EEj*Ud@9w*8=n>8 zJ}ZGgS#@x~Rga@TBb~}>fWKG`@u1ZRf3;FLdI;%MRw^F0n&9tNQ#@id|SWwz0tAzplkO<&+do5-5&#c0EYHJjO;;}um|HAb{^KT^Rccy6zkc; zu)bY@4ea4~rab~1+9UBSdlWXZN8{P{7)-Ip;yLy>OtlNKv0a2s?D2T6Jpr5A6Y)HI z5;n6Z|ig%OnVVtY%j(vdkJ1*mt(eFfgSCo*vVdom)grQ$6kS*?Uk5oufi_& zYV2yS!EW|C>~62e%j^x?4d9Bb~=Sic9R{R9;6qk9~si8%XD| zPcnWJ={)u+#&02=$3Bg3+ci{{Bb~=qeN|b3bRJu0yb|d=w#j%E(s^tfSKBU?50K7d z`;6BjoyQIte~5G*JHhxPr1RKy@MF6kZnPWVCw4=uv>V~4b_#B?Q}Hvq39B|EoyTs< zcq`I*>}I&lZcgP3r1RKm_@&*7$_`|YWT)d!yA74Ek*kU&4xa1G#iq_YJkObr&72ZE-zmlB&H`-Vlwq2)5L-Hnu$8kITRTfI-6_Wl zoC<8?EX50*W!TnPj_sTk*xp%*7dfji!&!|ToHdx~tiy|)^_b;sz)PHsnC(>ZC+diF z9%mC?>TJdwXDjv2Nau03VHamRc6D}OH)khycdGC*XBYNxcH`yF9_;DtW$hJ6=W(j> zN@pMTcJ|{{&H?P>9K@@gL(J@pya_sov7d7UuXT=Mf9E(}=bXR+&Plx9IfVnA(|ChZ z!`eZ}&eKr?dXb~!5XTHuB@fwoIyUAzF5c|;IMfO87AJwjoH}@`Qx6NA26&s(5QjUB z@OCE!M>wf?htmW{I!*CTrx}iNn&Vwg8jf~aamKrmH$f*I?{V7TSf?%Zdy$=|(;n}0 zGO*Ce#QU8rEON5(0jCp=cXIGSCl@C;UGX8OJ5F?Z;KNQ&oaFSvN1Wa`+3ACiI(>19 z(+?kW`r}k*06y*v#A(hTuHgw}=jjZ_C!IW;;pF2}&QP4`48y0L0xWih7EnVByk zJ5Of{zU)lJh0Zj5#hH$aoEiA4GZPm(#rT>t3zs-^@O5V{mOJzC4QD=9I3@U|Q;JKS z1?0bl^na%e-*y(_a%T~~<1EG%&JujrDQD(Nr2jh=xXM|I?>ozIwX+;Qa8}?NXC*V& zBK_Z4g&#VralNw!KXTUL24_8f>}QIDe6BW{ogr_KRGqH-{HSDr~K^bc)&677stkfj*GuK zJ|1#H{LM+=VW$rM?$pC0P6PbIX^2OiMtICg!Q)OU{^>Lc>HkRocbei!ry2h3G^c(F z>Hkg|o_1Q{zfL;VIBigI+oI~WN6pPZ-OWV9&0?*I^nW)SZMPFTZVtL`E_!ZP^xf{v z43Pfs_Q1&Pi3ztCp5gY!I&L4V>-NQZZa=K=_QwY90M?#~>^$9pc$PZ|8@Yq2pN;f? zHxJKo^D)&OijCc2*u*WsbKT+C)E$B6xg)WeI||QtM`Lq$47PB`VwyV+Te^kV$}Pgy z?s!ahC*TF{L~P?u!VBHW*w&qb?cAx@-kpXQxzjPjoq-+PnV9Jo&&6gnk z-<`|2Bhvrfd5kYb`oBA$ac89eyCvAgEv3>G>HqEm#@&(r@0Ky{f%JcOA>*FN&eL6l zz1+oAu0;C3yM*yoNdI@s8DEX;McoR<*C74hU5eMb%do$@9Ita%-~e|eUhl5Lf$nO& z!Ck|uK}i31*D)T9^nZ6f=D8cF+=TRhcO%~HR#F*?^nZ5~4s$nCxfSXE?pDUPA^qRo zhPS)hafG`A?{If=bR^RM-73bTkpA!P!qM(-yxZM_W8A%Xk6X>0vB*DbaQERjcR$|e z9-v-`^ndpt<07R0yN7VRdzi|DNdI?_;6v_Foai2>{xH)2-4pnTdy>jzr2o687*9d^ zzk3>|x;6N?t491L5c$o6tK)Rn#3x-FXSgmt<@(H-iS&OrWL%8&e>Z`%+&Vbht%q~m z1{{3`>Hls+#?K=C-))4?xhYiUBmLh^#S*s(l^2ly?>5C3-DXr4Ap3u}IpZ><|GR0p z&~1gUxaqjaZG*46ZE>;N9$#}aaEY6Vue(`T?q=f~ZYQj8bMQ?!7nizS@h!JIE^~X} z+ip)>?)JiW+}^mt?St>SeQ~AR58re9<0^LmzV8mi)$Sntz#WWh+&oW z6YvXnB5rplvGz-(|GSg%D|ZU+bf@Ci?li1&r{g#73})^^`oB99ce};-ojVKnxO4D( zcP{RA=iv|Te5`g$@JF|lwfm6%?=HaoZW;dUE~I_{>HqE`Jm@aQU)?2m$Sud;+zLGG zF2Wq8D0j(@l-@Tj{IkGZSxxVswvbl2bscOCxauE&$^2K?LIh^O32&iD_~|J_aa zue%v*+^y6VZ!2Tf+lHFA9d&O98s1Jcy(+Z4U1)o|(ed`6>+MC)t4813hk>^rL+=1a z-a$-whwu#VFxK&oU|sJh*7J^IeeVP|@J@0KXCnRIJB4R?r?HV&gJ*mESIm_ZPsejS z6H`4K8+$G`@%)6UoQw2-FU0e_1UB>P;Q3xXZ0Q)C>q>rSr2l)}v5VIOyLvsbo7W4wd%f{8uMabOApPI#i#@%5c!k#=dwB!! zN^c=e+$m-#dWMdk3+^JA^NIhq2T9KwxT7D7Q{&;l! z3F!J0(eo#v?@z|SpMs%36(fHdCj9AmhCc)A_%pGtUySwqSy5*58T){cTikK>C=!9dGn^;9!3z4)Lovnuqi;e;4Na zyYXg!4-WPBa`YCYkNMSjtG^Em{QY>Fe}JRIkv`@h#1Z}>Dt91#%s-5G`bTh-e-!WX zkK<_n1m5kR#4-LUyvIL{WBnSu*XLiLQpWi@-shWG=-YU|?_!bf;{$$(<6sNWQ)_|5P!zd26z)9`V>6;AWh@d>{TPWRj5 zlYV=g;b-7eekRWJv+!v@8;ku;_&+}fXZg7}+wY2V{O_>G+;M16TPo@qND-SNpT@1Ah*#@#o@Ne;%&$=i`TdNge)KKho3uQpOvQ-(&a- z7;i-Ov3?m=`U|OiitJNZ5@L&Hl*7!BZzi3*Q9vkSW z1t#i&jYi<28Te=gA=*I#ouCf7K|S<>2IvP3F$fxA7^Gknq+%jyf@cIxu};to>jurS zUXX_MgI3reNXIjSHrOy|i)RJxu~CqLX9t;>5@g{yK{loaov?9`gH3{5JU8fyO@r=u zUeE)Z1wHZnpcgg|dSi>A52giuv1QN?TLt~Gbua+agMoNKFbLZOgYm*358DR$*e)1~ z?So->QBZ&x!Eo#ljKIubBwieh!mMC4UJ{JK>|iW*48~!npb#$&iZCY_kDY@Fm>W#Q zF2N-18cfE^f+=;`yCQqSU@GHbNG}bhF)l!QX)vAfaHN+8GZ>FR_JqMq92pc-xfAK7 z!7RpiA-y!1!}xBbmj-hg--GnhU>@EZ%%?IA>7_vl<3gmD2BnOPkX{-rU_2h_r9l}^ z2o_R#2pfU&PrNKs=8&pzx7U`wICdSVpy)@X2 z&j(wvB-n;81lu`UiuBT82jc}uFAa8LSx|*92fJ`#up3_q_AqA=(o2KAxHzcB*Mfc2 zmms|~*w45e>7~H|tOyQLc@ycS!6AGrIE>4JBh=qUdTDSJ-wBRWS%LJ@-~{89NG}ae z;;P^jz8{>Xz8dMJK@F}6_!o(lwSkW70uw(B?0WQ4q?ZOR;|)kJ4Sd`fg!oC2z{;Qw zM?Xb+X;6>xXGkv%8sL_oA(gF2FAW;uwjhPd7f3G+Qt`{636&kl?*xLTjCUfvG-!rZ zL38{jNW)!0EBrP{$K632{4QvVdxG}(eUO2BgG~G($inI%8-ENs;l3aTe+qJOf6x_w z4!Yxkpa=dE^u&WfFZ?y=jfa9h_*>8y4+s75_n2?pRF!9YA348miY#J`c^TH+AEG)u?pOhpX{|a1FKz*WrcXdTbkR;Ee5%UK(!1i^59G2scsh zfb`OEGhQ5S#jJ1}UJ`D{>~IHm40mFuunI2?cVSMr8#{-4FgM(bUBYVY8t%hx;ePBM z9>B}OgV-ZHgqMehv1fP$uLzG~ukbk6a3%6Pf$#)g6`sUC;VHa2JdJ(B8oVY{>$8^* zb-Xq-v43dS=f9tT{7xWr@%qrmfnkU@gb5rJ*1;RYddwV*?4`p7m=`w0o5DtzAEw~V zVJZ#{n=tbhWG@{y#aqK>SP(YH+rl&)9=5{U!*m=Gw!u5Xwm33uk9UR{I4aDQ;jDcE>Hpyfd@>w~ zGs02$R5%)EhGX#Qa4Z&wusmFhZ-h&*A}q%@!wOs)F2%RPWwHpz+Tpez}55kSOCalD@;U-)cZpIJ8t++nih98C7S-S!0|KSeY81BSR z!YZr`cj2euZrl{^VdiH@{}1=#marPPhWqgIa6fJf58xN!LEIi5!Y{+axFbBm+OLqk zba)iM4v%A1c!K&jNdFH{;zmfhQW-|T<>HlFCqdNGcWy>!%@@fk?}kNRNUs4v!w`eFU3 zKQ@R4;F-}tY#0r~v!cPQYJ~LvD35Up(*L7;OpS(8X^iy$Xc(Rw6;Nr4^#5o$Hj743 zIUni&(MZNEkp3Ty!j{o!Y!!{c*3npwrX&478ppT|(*L7EY#S9}yJ$SNk0#(n(M0BC zAbaU(5@tq|@#1I-^(>_SM^hPRBmF;`hMl76R4zsOe>4L-M>8=uDyH5A>HpCz>=wqxyqXl?XREB+`g?M$eh&g?c{vR!7+z;vh z(Gu(*mE(0$1rCUoa`bwn|3}Lh-+=W0XgS^(t)Ma(>HpD6%!^i0xe4k2(Q3RoT0>8W|7blHL>ut7Xd@1fD)IJc6OM>B;~mjf92srHJEQG5D%yc}MLThHRE2j( zyKqdj8}Ets;Miy{-WyfpxM&~V7wyNw=m6dy9mJyO5Iztc#_`b+d@wqS6QbkzP;>$( zMkn#%=oC(hPU9m{4Ni{KGwJ`4j#DD@O#bZvr2j`YJ|4L^E%K>9f%N|<#3!Q!&WP&Z zQ&Byf88yJCqlQ=`IQG0wb%D@FtCcYG9VOf-oFGrnNyAbLBQ4TJOa`DxuEA_=l|Bt%klBfs1 z9`(fXs29Ew^~Q>*555`o#ida{d@JgY%c24Jb~F%|M}zR4XfUpb^6=d#A6G_0@x5pm zu8ImcHpCv{4g4g>!UIFQ8X4eMC0(|s1P?sMfgcH9xI~> z_-Ql|H${{1vuHAIj;7$2Xew@vrs3z&bleurz%QbixIHSyFQZwwBbviCe1-J?XfA#o z&BLl_K7JFG;I60?zl|2)?x+mEix%RZXc23_NBV!X7=MVCV0BcEKSmX}FItK}Ma!7E zAL;+may$^Nz+a-3craRpzecO^P_%}bzajlUT8F+wjm0sn|L;?bxQk42mCc(fV+ zjJD#5XdC_&ZO4<*4*WaXiKn6}{3qIlr=#8E|BLkhXb&ohy{IOtQA_MYJ+U8+!~tfS zNdHe9L_2W^oy1{u6GzZX97R8IoS6aA{}U%LN}R+*;uM~dIE{4@HCQ*nUl*m+OXyfX zVPb=X-H?AKi1h!2i)ST#Y?KJ`>_h@n5_RyLL_JJRG{DA*hS((02+vKVVADh@o|kBX z%@R%V{6sTso@kCO5^0#0XoW2k>DVgK23sfEVtS%I*Kh%{|4(G#g^5gTo5-Tx4(b1i zY`iGZ2{RHo*ddXNnTf7=aiTkBC3@f`iJq99=!G2Runqwwm) zXzZI9gV!X+vbG=6{}bb|f1(htOBCUN#CW_uF#!iACNlE|r2i)-;f;yOI5;r{ha{$A zUSb;Fl$ehBi5YlvVkQnv6tnggWdEO-g|{Z=U_oLo_1lpCpO}ZYC+6dbLoq_ny(q%xLwpzg-`;@$Fg0 z0WVm$Cuz?wXM8^I>#gj45i8w#_k3sFy!<2$JDQoB8LejIC6U2~b1KJ6a7ajODy|~b zIe`QMhH_$v6An^NOr;1=2}LMK5pY6^5TFc61#mv!r@LoH(%N?>f21lE?N0Z%pMJXg z>8HD&e);wMrakjxxSg3F$K9U!B<`J=pTgal`4nz<=AYvBW_}uXcjlkrzJKPQ{r#(vOP5uk+2a^Aa`>o0E;eK24 z`?w!W{s8ww$sgf!U$}oY`7_+Vk$e&N`;yG7m>rT4-0x4u zaQ}9a!~Hu+0r#WH5!{a@Q@B5n%;5e|lHh(KIgb1HllyT0LGnu6f0(@bRo{8v8+hmF zn|Md)Io|2{O}vA1jrV76xeo8i{8rv*`PX^tNlZ4;{IA zO!%~W9dAE9iM(&*&8KhVEv5^+^>oqLK2$h2cEx=tbB#}fcP+JDExW_JfWD3Ql6Jc@BW*0PN#fj2r;Z0eJg6%4=)qtLb^1-7Gj+uJCuDKi;)8m>U4mEpyb5s%eml|F4VWT(pIrlOP^8b+3jK{ z1g@vW%C&l}x6&-u6&J(j8ns$l?o=D1DnYH<>78qAHH)pZWtsRaX%Ip;U^Q^5*xp=8 zJHt6$uC^6kUvP?CaOFmA!8I3LXTcP;;C5rB<`<)u7WY?sVKFKuazNRyn8>jhIRMn5 z(K@~8mg*hc{rv9YcD?+}qI-ARyHTudr_04^i(tS0=c`uvVyg$W(%43Ii&lX}RbbJT z8gkEciuFZ@GI7hjaxHDI6))+k+42$R0o6-$J z=}Nj)UD|5as^x0u0(rHFT)BE}Y30J&>ZPUY{lN7LXU?zPICJ@l3+_s>vw87Qk&nAe zXD(k{TV9IEFI`w!SvqqyGG4sAx^`iCW$E&@t8S@%wb+m73w=GNw6Y$*e&+nrg{!M> z^~@8z9Qw&yH{Go#Z@TM^4pQHATh;naR~)L;P1kI+tE%2jS5B#33#MZCrn{5Y8dR=# z(>;0fmb>}nt&qnp<#B6Y9=G~=+;Xk7-KcFVzgrt~lvV^KUk5zRW5wu4P>Nt90?c>FH1Z;~FC~JF2wD+P zA5)clD<^_d1RD{w1T?7_5HmXPgqT3Mz$;Mf@L6ALZQ!Ir^RyEHOm{PD{>#Mu+X@~*1%+1F7{Zq5)u4X zwX%^`G-fJsp=Efzv=xXf4?JQmCAo`u$SrS3ZUt8wwL9r5f~OUCK3y+v*E%aHxV_ov ztTYXIS8%1sax1-3tQRTYwb`<)d0ANM*YBh)QJamOw6#`hv?_R1YBMe0?vq)_)!IW; z*0E`;PV`o>*~E*&6`O0VVtpfB(>6^JSE}{u*7g=VM;7WbqTavY*lv1q`$f(89R|`tmVaOTC2FX7vFJvZS8Eae7joT z2&B?sQfQ&W5$~?nD~G99SJW#*18)$$$* zm+DG)@3@fZy(4Yod5;XOurL_TRy+6LxSqBdDCZj6_0Hk?t<)M{E^4JyY~3Rj3-rTT zE!8XOJ;+khmxwykV!v=Gqjl@db|T@{8}#c&bB&tqutz&nSIF9`Km4$1bW0-6F!=D! z(u>_iIT}0a)9ymI*^(F*U~pg#(sBbY4B-~KAt3{2!^5q#v)QP)dfL$tt}yzLq{RrV zxa*|E+iA3JV^hkhTN+p;*O6^RM-HWO*g9bfHkj`i?!5dHUq1wina9tOJx)7LE7MO%Pr|>uhF*T z=k3tOAO2_*tu|J$q2$}#q(wR1QtMHUpH!?gnLXE%qkg6#Z6I1Ww-ZdATdtxtMfdH3 zsmMsC(&a|WOoAq+wnu$4;m-Dqv91g%X{}RSt%BC8t#)V4&s#PxmD6Ttt)nPObFEHm zHMUAs{GfS`n%et=W%lvJ`zozAlmt7WQwIxlL}@bBlLhzW>EesJplq;%4x!CTUM*^c9ndP&Y9f>)*F>dS{YDuDqF=O*^-VzhoYX)s;IhNRX^8wA0+CdC)$kaeuy<# zXkPa4-hj5$j>rB3+8}Sv{Od!^*I9I6 zlq58Qma2L%Fh~{-UYXFh)LRD+NU(Z@hvtmUZEx7j`q=d2(Q2#54CQgf?+AQAn~FQK z2iO`6WWcOj0+Ll&Z4u38CUs=v&7f}#cdhizdY*9ynY87=0M=?N)m_G=`czFwX0|P` zq-D>wbw4{^PK)cqa#yPB>#{pK8g_Jv`npS7>y~)KZv+h&eE?6{7W;kxoR9bz&PP1p ze8dB`G!K`C&Aq#?)wU8r;boSAqEhQ$NUOk%N-C-@wucot4dLZS#b&&Q1D`Jx&l>@!lYvEGFqd6t0Ti~)S{^Gj zh+MJflE%?T=Te3@T;TvA4E3bn8_Bmm7y)NBKE{CQKSptU>X|ta^1~%2js#}=z!3q5H=e@bjR$DTmly$OH6bPf zPD?|g$jgaS(0AmG6i!R`sgxyFr@YCc!27Cz?SRePdBmVM&8BXNgJSRd=J& zYOuC)jnX%>_UP5i=P+<3c2AB4xd(m^P!mndU$=gx*dp3L_0%yGd#c;6(qma$Eng|N zZ#$;NMym%~8*!o%u9~?GR#*GL$8CkyTHP$xueIz+v#6KTc6*pKR1R~19zdq7Dr>Q4 z4j*lKOV9JYw8r|pj$5W1o8HdUYAh0Qw3M^8Gize!jTaCrKp3CW+N{`#j*WQR?IL4}RnpS3JguRWkTCBUX2$tU^5wJ~Hf((Ktff&s5^D}8pGy~3L9P1RZK>9{={kCNMAWtpvI zJyP1$4RWp49eXpkQ8dRd(zh7j1cS!>rHM7F$JVs5Wq@;Ig{3a}QZe4USR2rVaf)&p z+nuxATc~Ysk(c*$`+*f5D?nBCJey)I7K0@Vrj19Hwg`>oOY*c^u5BAD$)2clHQ=f? z4ob^0Lh}ZV;zJxSb+ltp%1=v>sya-plutEzT^ZDwy->$SsiXIdLvOR0A_B)hgwU*1TE z(s90H268|Or!UuZSfsoMF)^b7C2h8=HHtSq^m~z7v9#T)JI)NP|E@M-19LHhEo((L z2DWQ`Y6z)|J(;!|ZdZ;%*P3R+@y->SF^_hAfOjzKuEAB1JLw8%$o-R>(2;eny#J`$ zDA7I)&Y%02h0J6PLSKTDMpw=i4qhDhn{XKxb`C>7vTiB@o5iNvu6xgFB5K-gx~*rU zMZH|DKkNM5(SBB)Fr3$2Ybk)HH5Trq1f`FbUt<|A`T(9{ihVx-R=z%lm2XHK0kgcH z%0mQCygdgi-;gG<@Tf2(7C46;YsF^ZEI*vyvvv_(lLgak%`0LpP@@Lv6M-UeF|8(o zd=ozc*3m(@NMV~CL=ez~aE^I^^8+M_z;qMXI70UPtgJmisE|VhrBH?^vw|^UK2Zd0 z7Xoht>;OWd$jgG(60rsjyb-W^?N?T|k_ofXVylR0)Xgtk_3~vFtH5}vkc)phQVyrE z3aL%Q2^gVMx*CXUp1&v<6{<4sFi8>LUr#Bq*}7+g*xus-Y0zx|4ErE`1dJh1DMj80 z=;nbp0{Ua1L|&e^daXo&-aG}R1Q2;y<_8T107svt<<6AaszDJi%+^3vt6UP!qS+>28W{K zL190>LI36fDxChM-dF`44rz~b738Ew+Bbn#U@u+ z8+DegYj^zahMNZBFx{mU&UHAAwFkLvvm#!G8#-ZX`^V^%Wixo&#p5&`o3uGwD%RUN zi}XRWVLXCDebhgK)@HkZ3~k#QzaQgbwe7?DtuxdYctXz^EoY6rcD2p2xZG;!;CerK zXn(>YzwIyQwrv1uZLT?}?RHWf2eO`tr^=d<+6PbWJ<*#}k9nkz!q5lnck<-$kTrQ< z)VMM|#nv&Lv8wCXQ+u`s>{a~AjDq%s5GzK60Wq{+V;STZkSee3&hQL_RWpv%daa2p ztZ01OoZDnC>6W#5^+&GO8OW;v*^E`&y=$9CQOm_P=};Z*Ek)u;ml{5ZqIEF^#Upip zKuzB?IqszIG2KyV+Ed59q&dB)CrEz3W=Mn9x^<|%5v_9%6VP9~V^Jj~=lCDVnQ#q((c&vBWb5wCFJiF)%p~*r`YW85$+Ni&(_T4>- zE6$qwzq3PeDJ-n>*eZJwZn~<3tf5#!%q))`fE6@ixA>jt`!`b*Dcv8 ziGL0s8rJmt1ii(8d>Lytg`jG^jGYBkQa#ZYVt0e36e&F2)kUawx}k1iMV;Gjaby#e zcHxd7W8cocHNAMSRjqhXY;RX%LMDffJ;Jpc0+!k3Vx1#<(XE#bB8ILKgnA6(`Zoy} z8!dmQz#C@IQnvhxTjrr;4)}PYrcEHX(~;Y3>vcAd_kOl%a;XTqY>OuMF1Ess5ZY@P z9+qELGFZ7ZTKcaR;ZLoP`kP zRzI#iiH$R@%}MtfXgaz@d@Gb9p^N zpk9&6>9u+Tx8n{_N;_`3v4c)_n0I$fgFDgd`yO<6LQF4$-3W;CNh9b+(2HO<0#bY4 z2)YsUBG~PN$V*9dGJRv3)K@r^udXdF|rRhh7f@8VC-|2-cBIrf18vzRTstK$+ zB3Q8z^di`eyavQWqlAjaM3K^qV0Qpwg+m$|GSm+sRdgez7r|~HgtRef@OO4Y77_F! z*o}Zr5i$>HMDX0<2}? zxQhpmQ2fCoKnIU_DnmYIM_w4v{?ZF4k&(q7CK|I=)IOv;HXiAsO+>6^J+zgDhyMP8 zE@Jp_D1}vY69dU(%E2_slY>^Z>zZD&ov!H#&(zfS17LXj7{;y#3``FgdOl4A45q*v z0i8Th@YPWmr9Mprbj-jJ0UgKl8jw(oXiF^Lgp7d34v8ZRE$AsUj|bh5Ci*)8AX!Ko z0V!gVewscZ@M1zt1Pn)?5-JoEV_b}a!3ey45C}V@HWutd;EiB6f?m*Qj-Pt&6m zJwxGYi6zGq2iqD?!De_b!UemBW0DB2;Vf7rhGhDOB!e;QD#lw$fFS3H? zVVq-;g*@?$|3>(NS3KkIhdo=WaT(=BPutO#313n)EH_N`{X)qsZ%1k--xPS5vX zpKOPqlCvK20Bh(0*3bj2VT|%g8Ie9s1X#t8CIYOY=QW@L7`;AG1enE;C<3gZr!c^S zH4JG)P$pQzAVQ>I4MUm;x`87k4M8kJsAm8eM^C{Pha3cK)e|XLkxdJJMhzA=jBlh;oKSi_J@1T2zbB46cx3JdP+hHwO30b6fH3f3^>5CNrl zmY6saFpr+K8v*7qB#r<(?PZRDMUtmr4Fhiz;EjM`6?h})g>pQZxiXXz!Xj8{F^1vhDGWCcFhGF^-)r87F()B6 z0m~%e0lm;GCjthhr!eXQ?*If|=5L>G1oYs*I{-f0-H>D_06LPV&?!Qa2-p(% zG!f7!fj0uQ7buaJk>N#&0AW1^Ndkzx>@b4Z0l?8`*|m3H9Iv?4)p(0Y(XF~F?-NO# zd*pndHcO1B&Jwrn+Jt9aiTGK^LubNO1X*oOM1T43$793#)aiV&PS?*Bx~2X*On-`0mmY57&ci-aXNb!ER0Q!38 zzCOmEzDK<0T=bl$Bd6X~axkX>r$?uKn)jcj?Cl^;#a%`wzWs`vD?$1el(u^t62VWmsn)anMws$^ zRA@@FZtM${_JxWu^hUl6Lp@5S>8Q!Kx$8sP^dU6*$S`lz>b-cQ&iX08ex3XqRaEflfjbFO7*h>Af-!jPQE0Xz$^sBzsa&GUthBN7+cHdFHQgOU+57R9M z3VzeR(3|&#-m)+B))1m8E)3*}opw0Sg?;H3_k~Umayg;l)afhtzyNxKkBxO5j*Sg$ z18u?cMLF)x7n{XW?EPFBz1H}*LciVx4_g9nSGekoaI!0k)hN-3*q}8u(hiRx<)Xf$ zf+9DMg}^FDR)yPF^z;q0LF-_R7UeHH6y@paBvpfVI}L1uB(5UA^g`()O>Eqkw#&n%!d-}*juEeX)~VT- zmz7jU2E4qbWGG$CO;SeulDR$M?W~_Y4r*z?KHiHxS4&>DwpIz!wdRwqljYT%({WEr z4@U;My7#V!_Lkg1QH)ykpV ztair_m*SpQ-PJ$ijQZtEeJ?ZOm(u0RzMQPYnfcgN}XHC>gVT!B@gQtoxSEu zr*%AV9j?Wf%2%Uyu-b^De3m&vTG#r(&zi&f(E33AMYE`S=z{vml=_KgRL#ro=~dDz zEjMP#KlZR$>*KyH+r$?M`&Eld@?$32j3#jnTeD~eZ^5f{wgP%A=B|~D>{1U7t(3f} zJlszX=;V;Za#9Z$*dKYJN5Gl9D_! zNWDW%WPdivOYl%6n_NZIV}wKCr7X z2Wi~vS-nNAUq-je#XS<+vqQhre4utzUs5mIuM1ISd(`UW^YjY*>Nj=c+|PS>R{08R zpYU*gU+(UTUpxExQS+y2@E2RGu*_gZDJ`JSsV%E>SYIZxC$a%ud|PqeCyK0H1#YeQ zjrvMaHO^wc3H0NlOYeT}AMi?_nti3cVP1_xjmAKGwbH9qFCaw|evL27rFwYpaRVJ_ zq{@<1;p4>?r1(GHi@Zjw|J#=94(@~2cVq>&T;>fG#yxj%DX(m$r4}zaIv{Ro!c6sMZI^kq@~lzT7L*5}Xt$y~GBet#X{7o7FyOF!B#^%_ENit;1seMZLzTI3- zI=w?!Hekc(l3DQ@K}yFPUCe%|ev;W0)~(DksLhU2ZR~QYBdflgxRj6d%U+4{e14Kn z`IOOoA=%&?>*(cXS`SO-lq3z-fMcV_baW^rWRTPxG z!J@%wm+@4|c-)PX=EQOL3J0$e?Q@T2jQW64qdPK@nVdPAA(v_<(h{FMFWRIzU*$&n zfgy5XHuKuO-#&^=V=jyEqx_!NBr=GS%Z=uf3PH)2&?pF)RK78H%rYA2>1Atcax|CC zo0A{O(Qk5@>}ZaTkw2Qv<{xkmWJV{l+3Yw!eK39h{fe2JI65+7F%M+NCPs+N@rwvV zTI~Gf(b3Tm`@qQf#3=Ek&Q4FCxPL4+lF#R%H_r=ZE9*fH%ffgn&JUR73dK(4Rp+VvbZ)XQ5K znPYn&qHS@fy*ENHou(Pa7)>82WX1|~Re|GfY&MfW&S6wGd)ys&uuL(w(It)Y6qTXA zE_>_)37Q_~S1Uk@d4@JZJh&%i1STi5o!dA<nPF6@h{=WA*O*Kuen8$cnN^Y(8YF>} zn!3nMZ_hf)<0b`!5xpcOI`Enl2%_5D&M;?<)jufmu z;VI%H7Ux)Q#(WXNpY`qM7-aM^b;K-l9%DACSj0z9m%x{@`B~>@im_S8>@bnbMZv7qE3eKSrO}W_1IYt= ziPg;yxuo{$+}Mf9{C&svK91em`+M1GgfuTN`*R{QjkotHT6b19jbDiT!0RGoVHV3o zy5kP=ApPhp)+rZqQ6Qr%KEWa+mpwtNVtklVjg~WxwB=0rh5WQ-+UFrSoVL)9K!hls zdw^e?&rDNG6i%M1?xzG)txsj93wR$hLGot&j~>s6L`^9Wn=Nl_f&?#!>_ujpKugCn zQVEp43SXGb5ja8SC-VIBT{5ql9NT-5ilA()KdT*PC{xP^-h^7vl@;)r0#fG*J>Zhu z2w8t3%8T~Z7AG=!W|xutjK%(R##evZdJ!StTo(FxX8Jh8!a5h$L>eRy&Aik6*~Iu6 z%snfINy?n|YDEmDwiDCi{M@i)()9UaztV<2nT0fczqLIze_+t4l;B$*m5E1&=W_mY z??3*ziD*mb8lQ6)rxvFBPT0|g{m7Badb@1xSX6Eb|EhR{O^78@BYcp|L8mN z$?rb+f$#sD&pw*}svDpGfqyjeZ{N<(MrAVD%t&T*^gF-jeP_vG@2zqk8U3}HrFY_d z)||(b^~0k_f9BRZMpy28%VVSe>Q}$_(b2E_)jxmd=%c@pIinQs6pNg_$40+aCG&Z= zoXqIiKN>qf`cqd+^$G1x{BCMuu$h_|?4|N?E0r0`d~qV4$F$w(7x~-!_^Hg^cO1B$ zGyH{+Q}NEu;7cO?uUgE_4!wVEZuSQExcOG!ql>2&PR*a1pPSXUz<5#AqxE!~FFe=g zW|w*KdbP~ur&Zn>R)4fyeoJZL^!j?avRJ89-n4LEW;J_s<#x46PQ2zwFQjVJGb7_C z#u<$UCH@Db_6G@ty~xJH&y8MndD zTLw0uv*Xh$>eDLWMLGY$QoJ~!{A}PtP4+%#1(_vNhA&vOAMQ%@n+&nc7=|=+!n@f# z{mo{S*SI`@y^cAWo%$2aBr8aZ^9uxn3ken3T{i>D`G z4KzSgZ*UWAWbPR^Xpp1%{7HA@`pr}D?%`PHtF&1a{M9^g_G6D#?$_i{(Qm%oqSO;sU7PX6fBEABzfzbbUsAy#EF zm7jA3>uE&T=FxIHu#Kb|ie;vAxEBES(eX`9qWGyGO0W&lR4bI z4<{A*6_5mDd8j<`L^l8*zLxv=Cu0P_B2E%vWmXVbd&vxrV=swQlhZN9kR7T#N+v3M z6WuTpH-&3nj_28FD3t6yFF9Cct2jK-4OnqC)~kw`ixB{em?Oe6t*O!oB@Y=VXA8C= zwWWDMjaAb>6Q*XuG$#vW*o3eJBnfG131XW>NoajQ z{-lqX7ex^OlW?90%M^`hM~#BzBcqp?y))7wu}NOD?!%UuZN7=wWih1o>6$Pf_I@`s zTQ})S7(5S73Rq#-ODCi0*m&cx<)##tb*k81%aSdaa=uHDEbu&-6R<4qN}znwHMzPr zO31*{vh52x*<;cncm5wC7X!H1e(GQ`mcQpiQHDtH4B%f`= ze2=Jj1@t>crQpJ;!{nr6KG#3(NApHTkrQr=MgX8wGLmZXu+s8Wm>7x7)9QB8Ai^9I zfC%%N2}HO{T?-KW7FSW5%2CSN)M%J;1?w{+s>~7VOX_22q)SIM<<_j(_L7bmYMNs)kA@~N==szZ^mJsHqAl>y zw>hNu2{8#25CVpbEARXv(4^7@bs=6q7t2H;D@X3+kthIVjJixg&SXKT;F~}-r}@pD zGk4xxPnSj*z2Pr9mb+6O)1~-T(^LybTiUXpH+&Us0b>*O1JczK?8rP*cAU zMHOrc*cqB+<5WRC43F+B!etS{QJ1ORtIhY~yfu~?5G$yLXi`#5o&N}mQ{gz1_pyjj z|JM}jJ9waajsYDWHQ%afRar#HNRVaY+=ERu>5ZB^6Nk-bXb|XBgvqAE7}) ztWwe*q(tL%adj=5vuadFk+hH^IqXmyNnaiiEp`kw&7sz_ZZ84Ro5hfoGN4_Rfv;_s zH6N+&VzkVc?IQASGAFIbI3(SGWLsc`mfZCuQ8ddibQ#Y+lB|MFm{&$jsNS>e=jf;^ zW==~?V&}X$!6-{)Y?GGD0>q0CFlPjrH|0OCRj9>;O@`YrqH#{}6DO#E!)IzdLEmDP_k)DkcyYEenP)m;*Qc{1cz&2lvG1DB&BFD< zG<8;Rl1D@eYD-oy2(SrEr$DddQBpjo>E{bQcM5Rc$_f=JHmuY>!5XZ zJad9$4i0Gafs2zqGG|X20gtOZg~)L#gr|C`q4LvnE=QLB7)d8Eq%(Jz(m!OvUS_6o zvLgkM=Vn77DL-Q&6|WIxlssoLtR@)Vtgw|cv07qI=clyI+50pClk!DddVJcH`6As} zdC)g4E7IZziKg7hAb>8#c_Lq6NzE}8`{OC{9w-LetCJf=Ot@HopZ|-~31?O_9G1?E z%}l;}GCz}CIzBskZ11y!c~j}?K5%a(?)G-bpp4gS!wB_+>OZehMzORLR#_?E_zXv#da;w>|dJk_blDH~PK@$*Zjzr!5AOqa?rS)@`xl>>)r+t>FRHg^DQT9MO7LA* z-iNIhe>l$1-l8{v)gNp%8e6mHUO%h1WzO=}N!~?0%X?a8FI`@_{9wD&tEIENQh_&0 zr7iNPq}&KnVe_Znr1K}|CitOV3vj~*kz#e7dvnH3T~F-|A$*JOiB|3Po1IRx{kDf5 z+Te{n+oeF%aseIZ^9DYDOxzz2#fj;toT0z#E9Y0P|Kbi%Dc+j50%lATKJ)Mt8BXFo6nmMg&y-=St+@dOJ^3}^kx?dDLowup(5LR6Mt~(^FRKFo#kKo-S4@uHK@dz@a&M3L-3o1 z_?%li*J$w$#4Efp$8Fh*@Kbv`Xh`tIuO*vENxp`Afs6GkZpmG9R|zlSUIgp!r$>MP&(%YsqND$q#rlJ5 z8z>wIjx#~8{Mef+>F3b{V*}6HUzrW;0aaW1e?Im8cWOr@Cohak@(b3-|(+o1{ zuB*<$d4utxoIY(l^VHk_<3}*11NR)imYd<1ejD87(S0%9s>1w0kt8Zp_rY{yXp5#k z=U$JiJ4OBm-r&BLZqCOW@0Yk!y~0QLxpWIlIqCMQa`)eM@OSTYI86tY-set`_A+Jg_HAg=b`!n$yY{;4>f9&L z&=olJ-JDtagLI{voc<=AzB8cPg}#ML+<(${1|+fm&YEo3LVoH2DkbVA-iuS4?*Goz zu#8^+%jBUp*|z&wgW4TVt8(WZ%Nn;o%|T5{e_oG{o+h22L2BLa+M);6(1Ne+u=WRO cyzW)j%ldm}%uT}BdGuv*|9__c$29Q20rKk6_5c6? diff --git a/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.pdb b/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.pdb index 9f865c099e43d6684568b4ca74fb4e487c2d05f2..e0991c7100204f05fb5c0d8ae794ec49df635b2b 100644 GIT binary patch delta 9098 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 From 6e0e2feb74f4d4289f71128a139d7d1758a9c900 Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 10:15:44 +0000 Subject: [PATCH 21/93] org.gamecult.cultlib 3.0.0: release prep (version, changelog heading, install pin) Co-Authored-By: Claude Sonnet 5.5 --- docs/nuget-packaging.md | 2 +- unity/org.gamecult.cultlib/CHANGELOG.md | 6 ++---- unity/org.gamecult.cultlib/README.md | 4 ++-- unity/org.gamecult.cultlib/package.json | 2 +- 4 files changed, 6 insertions(+), 8 deletions(-) diff --git a/docs/nuget-packaging.md b/docs/nuget-packaging.md index 841deedf..7894fe5e 100644 --- a/docs/nuget-packaging.md +++ b/docs/nuget-packaging.md @@ -25,7 +25,7 @@ closure through UPM rather than NuGet restore. Install the Unity package from the immutable release tag: ```text -https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v2.0.0 +https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v3.0.0 ``` ## Rolling back across the store format diff --git a/unity/org.gamecult.cultlib/CHANGELOG.md b/unity/org.gamecult.cultlib/CHANGELOG.md index 3d03439a..b5ff6419 100644 --- a/unity/org.gamecult.cultlib/CHANGELOG.md +++ b/unity/org.gamecult.cultlib/CHANGELOG.md @@ -2,11 +2,9 @@ All notable changes to this package are documented in this file. -## [Unreleased] +## [3.0.0] - 2026-11-11 -Releases as 3.0.0 (major). - -### Breaking (major) +### Breaking - A single-file store flush refuses a file it cannot read (empty, truncated, an unreadable stamp or body) with `CultStoreUnreadableException` and leaves the file as it is. A flush used to replace such a file. A write set cannot merge with a disk it cannot read, so the file is repaired or removed by its owner before the store writes again. - A single-file store reads v1, v2 and v3 stamps and refuses v4 and later by name; a directory store reads v4 and v5 and refuses v6 and later by name. Each judges the stamp before the body, so an unreadable body never makes a newer store look like damage. diff --git a/unity/org.gamecult.cultlib/README.md b/unity/org.gamecult.cultlib/README.md index 5fd83090..30db7fba 100644 --- a/unity/org.gamecult.cultlib/README.md +++ b/unity/org.gamecult.cultlib/README.md @@ -11,10 +11,10 @@ and OpenSSL license notices are included under `Third Party Notices`. Build it with `scripts/build-unity-package.ps1`. Do not edit generated plugin assemblies in the package output. -Install version `2.0.0` through Unity Package Manager with: +Install version `3.0.0` through Unity Package Manager with: ```text -https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v2.0.0 +https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v3.0.0 ``` The package depends on `org.gamecult.cultmath` (exactly 0.5.1) and does not diff --git a/unity/org.gamecult.cultlib/package.json b/unity/org.gamecult.cultlib/package.json index 9276e03a..1111d3fb 100644 --- a/unity/org.gamecult.cultlib/package.json +++ b/unity/org.gamecult.cultlib/package.json @@ -1,7 +1,7 @@ { "name": "org.gamecult.cultlib", "displayName": "CultLib", - "version": "2.0.1", + "version": "3.0.0", "unity": "2021.3", "dependencies": { "org.gamecult.cultmath": "0.5.1" From 8eb77546eb3cb2017c73aff66363b50894fdba50 Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 11:36:06 +0000 Subject: [PATCH 22/93] org.gamecult.cultlib 3.0.0: plugins regenerated by build-unity-package.ps1 -UpdateTemplate (CultElementId, element ids, write sets) The native QUIC bridge DLL is not restaged: its source is unchanged since 2.0.1 and the rebuild is not byte-stable. Co-Authored-By: Claude Sonnet 5.5 --- .../Plugins/GameCult.Caching.MessagePack.dll | Bin 97792 -> 110080 bytes .../Plugins/GameCult.Caching.MessagePack.pdb | Bin 33736 -> 36532 bytes .../Runtime/Plugins/GameCult.Caching.dll | Bin 172032 -> 242176 bytes .../Runtime/Plugins/GameCult.Caching.pdb | Bin 52624 -> 70252 bytes .../Runtime/Plugins/GameCult.Logging.dll | Bin 6656 -> 6656 bytes .../Runtime/Plugins/GameCult.Logging.pdb | Bin 9256 -> 9252 bytes .../Plugins/GameCult.Mesh.Quic.Native.dll | Bin 15872 -> 15872 bytes .../Plugins/GameCult.Mesh.Quic.Native.pdb | Bin 12000 -> 11996 bytes .../Runtime/Plugins/GameCult.Mesh.dll | Bin 683008 -> 677888 bytes .../Runtime/Plugins/GameCult.Mesh.pdb | Bin 160800 -> 159460 bytes .../GameCult.Networking.WebSockets.dll | Bin 14848 -> 14848 bytes .../Runtime/Plugins/GameCult.Networking.dll | Bin 621056 -> 668672 bytes .../Runtime/Plugins/GameCult.Networking.pdb | Bin 291716 -> 307084 bytes 13 files changed, 0 insertions(+), 0 deletions(-) diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.dll index bf05fec2a315c1a39fce9945c79d4a4388eaf500..5b040bec59641a9f5f0eb375eb6792d5bc5c6567 100644 GIT binary patch literal 110080 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 literal 97792 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 diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.pdb b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.pdb index bfe118071fae484d3c60713650487c140d49e952..d2629dfd2ca9c004e0f373b56b3312453233609f 100644 GIT binary patch delta 17535 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 LlG@%i7&!Fb<*yag diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.dll index 8e7b88f1c2bf5512c8411e0665aae634728f6d18..cff0d028ee8461f448a4fae9f32fca61026911f3 100644 GIT binary patch literal 242176 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 mT~N1^7Pz1OG4h+P32W literal 172032 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 diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.pdb b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.pdb index 3fb019e1a9299dff84e1fe825a546b581c450b9f..d952c867fbca5fe6cc1c83da9bee8d3702d20257 100644 GIT binary patch literal 70252 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 diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.dll index 2dd55355cc3f6ea3c2207179d9cc083acd0e0679..f9674828b87cb2d91f5641b78aa18279d5ac37da 100644 GIT binary patch delta 214 zcmZoLX)u}4!SX}-S=+`QI}TQ61|Zmcp5rHrz~4t17Eiye_iI1Ny>$JBC5t!T;H_p9 zGS)KyK?VjcMg~?eXL2h4eHMq?i@GLz2m}atY$(^iT504r*;(W9f%|8RCa(|(R)7l1 zU4{yRRC^1)kh1#OzsNoA?W@hwg87WRV7r0VL)8PQECr W%?pz&O!>V>6?~e?|Z@c1Ab= delta 214 zcmZoLX)u}4!7{tq^54cDI}TPR1_lPk&F49OvIy*r-4m^tBzEZ}3(# z3K{7c=o#u6Ffed2GO&U@`XO2qwn<(BSeT!O;_HcJcUGxCD%2HFpC!*rpYjGRUcdJG0YY`8g6Bp-=8 b`J$*cFI*C&XtSW$2gZp89Ge*>{xbpq6@5bT diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.pdb b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.pdb index 1635f0938ff696c846b1f8abad12ae06856d8944..f881acde11841ebf5d3037eb70a3ce17cf9204a6 100644 GIT binary patch delta 668 zcmZ4CvBYCSfr5Y{1B0?tPJR*t1B1tga{a57Mt+l>H69^WME=grNG2+28eF}@qHkE2E=c{av*saU}pHDz|8O)h?x|b88|1`GrqRZ z1gd0UUs3A8X*P?MLH_+s@Q_g4IF%UTR zzH!gt4a>!(c3+4V;(9r)#feFEb{_xI_Kh~v*j0Kby7AU6+2=NY&57H3_x>@quPK>i z`uVrpeUEjoH!tqHJb71}_ZhA9X-uvmS@-?qPglIKY^OOThvIA!W(dwyHnJgczN<``^fzu=q`4DzBdPZOi3nEEB&y znyeCY?f;6OH?GZ@_vFaILuo!<{~i@Y-VJ_!Lc@E9#=>|5)la?B=l}DqSXcLU?NNS- zW7=0w_=Z(&{In!Psa|XL)zj;{H5Vq&_MX-v`FfsA;-cjZZHAlPma_f)DErPsaGS`b zEAq;T+cgb!-S;m~QR1%;*^y{((R461dt>3!m)FGnEj5nv@j9vtUc32H*ZlO=bvG|g zOR@^74&JL*Z28Rjh03p!VI}_`>MpPCxs+!7{TrL@)XkyWcPHzo+itM(vf|48T{HXC z-;BVI4_@(3icsyFzbJZdQ|NlDC7*X~TT`2-rM&N7Y_VAhRrF>Yn+VpRmo7VN9CbtQ^gf#JFJhv<4Q-AmU#3_@tjTh_k4@$vJLAdPFUs{tH8*R z55%oNJX-;%kb#L|jRF(HIUv3T#1Db^1rWan%Yo!!fSKW&0yD#3AZAfyX5gM&&-mIt z8>o_jff z<(ikWXf06Pldzq|MrUsSH!>zHev_EWt7m=N^7$Fd#Ba|g ztHxZdKk@U%QcK&Doz1OhXH5C?^x>AO1?MIPPAiC9GT%V;Q?Imsop{J~`)}7CvGY9M zn)O6Y+cvr~$V6~z{n=MPpC6raVdGg`_W9PmMazq}y~-l1N*2#HufLbEgR-6ED~qK$n2&&C|^UnK+FZ^cW04c=JY! T`HTqeujYb6JFp^bGV2^$ZvoxEL8&!JN&r4Oy93tnExcOkQFfAP_7tW#RREy*~T3zCKzj zU1c(v#Uxb$DtN{oDhN{jmt}i!d7wJ4lfcq{n>$SynRvm@06GOsZ=P-@$i!*HpvPbU Y#D<$UTFhrea3@DvX(M=>d##F@0UWSG>i_@% diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.Quic.Native.pdb b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.Quic.Native.pdb index 021de32d62596f4dd10eafce1e1c866f8215dbed..62bdb6b33d9fd4bd2b021ebb0501d398f984bc9a 100644 GIT binary patch delta 1006 zcmaD5dna~6fx-(71_ouPoctsP28Mz>?)lkQs=eCy9=HUAcHT*zyl4N!3CF_TsW38p z1>!$I%&f}Dzy-wffcP*Fp912GszA*Qj0`t`Y$G*Bh9n^F1>&hdJQs+Use$A{fRSN6 zkpC2jzpG7lWE2(wipZ)%WDY>-)XkR}FEcTAOkU2SXug42oq>&koh^)knc+D@8IZis zsLaU1u#Q=Qg`Hucx&jLa!+P}uAUj-R0gyZaBsmzaZT`=)g{l6~qfK8=8nNsA+kR$} zpSMHJ-$!0e2kMUN?}}aW>IGx!<;JsqtPBimLvCl?ap0+qzfmtx=arEx{%cx`(=OG8 z7j$#=Zns}zuhhBS^s#5+6PEg*`o-~K$7bv8-oiTlodnyl;*yeQ9xF7?_3fJ0y{N%k zMrWR>h{uH$RmOjPUhhj~trypBQV+SoxIl*SVA7Y$rgslki#a=Ui5(0|IXiE`VL=%_ zuLB2N1&i2JxBfU0$l%QC|HgI-i`^>5>(*x3nX_k$wv?S*KBXY?kK^Z!Ya{j8o?bX` zcZJ*CqYn-DFTFL{xbLBo9ha6-nwB6A3A>j7ITit&V z=DTFZvaKDhSAyYY`Q)dkBh31t{H%?kfq~)gqYR6u-`4xJpX6S;{=$;QaOJ+K#Tih0 zFX^v(z;J+z;e&sP-a)VP-YYncl(9tnU4^UlOD&nK!^&k->c+S-E{M_4UzO=_(UzJJSynCmaj=sKUtb z1Bm|vF`FtQ11}IS1ma^rd=`kWr~)-JFf!Z*vQ5<(8B&3G0uavt;srpwQVk>z0*nls zfczIg{7Y@JBcregP((o;B6Ao@XKcRAc$tZ@d-8G?Mf1(f>I`fQTx?+s%nZ*N%7Em3 zMrB48hK7b7&fUV0NGI*3xMQlAj!dSbMt?eEll;E8W{^%S7&tk#%nEm zt{|H+BVegvOR#oL#yRaRsn3&NH(u~#Wnfr4<$Ts12cBc^xqtH=THa#zc*}7WkxROq zt$CTB*SBA4wwacDup({Z6BhfR_{IKV$7bv8-qNaj*HYrm&o7hzc&^a#=c{_Qc~c9w zjLzIGBAypk)EU>FdA=^0wSIn9lX}Pvjs;ST2ZOluyv;wkwr^Br^-i$d^w8nLV-7iX z)dw#ob5u5WS=BsIY1r7N&c~?LG~r6aY~Hh0QP0y@7y5qERE?QZzvAbGYiH7%i*g>8 zg)IJ8^Zn}G0O1qT%PZzBjSrmqORFb+Klh!dpYJMt)$Vz`bY_96gsiBoWR+uma=c9a z?b$-odF?V&ABnwgV_JA=e?!>fg4;L6o*XZan51YEaAl>v-O;b9LU-l=_Fds#U#4xN zWw+>~^1&c`{x^w+EYXw76j~1MIM3TZagS=F8s{DNZG~D&k?u?reRz|5gPM5^-9ro0EFJ3w2EoC`r?{)jmCKt+dwj z;P`)9`_A<9$M|wCZ!Ornch09*5%>1VT3q?ydLv_E)-Iu(>Ml0B1*=XM+A}gTHied@ z78PgaZ(hSWg~@%3=i|K$2Ur;r^j(9qjx~7glXO4xq&GF+Jux@cxiqIl&p9zUBQq~u zFC`}jm_W^ZwrpeoCbQD=|5JFJO1(C|o;7*pg68j&+j*4TOUpvPGCW{psF+|bQFZX7 z`?kBS$sGm@|H94k$xly5m^HiE@*hJ31H<0fJ+bO+tG(A{#GB?#U)wSHBaetgvDx7z z3=g;%TJyYd25Q_F`sa004#Yj!ggn diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.dll index ceb2fbf42182902e5f831b2a3fc53b387e6b8037..f7a86ddeb2ea4d3ba417c0c4e21d8c62a4138911 100644 GIT binary patch literal 677888 zcmc${cbpwn`9D58?cTjxlAGPVn+90|8-~5R2_&=tLg>8;iu4W%<%GEeA$QLbdQFht zTcjvb6p)Tm6cIrM6{ST~KtZu!LzLzBem~F5ow-RsKYx5*zkMb1Jm)#jdCqg5^PGNW z?#}ys$?+V=@$vuTj~(YhK>p2<=chjwA-Z$Q!=283tGD z@UeY|9(>$!=7hdO4)2Q>9M?DZxW27+{cPW{=CH%3cXX82NJQ_pwd3s6;yEwQxc!ZU z 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

  • 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

    rcU6 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 jtVOR=Tyt*CJu6J_+Y(#@DD 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

    BAB&WSwN^ot-X`fax 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%0A>&`5HM@ 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 MCI9mG{~rbZH_Dq9y#N3J diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.pdb b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.pdb index 3074fbd63c035c4250aab5391384debcd3b850da..d27197b57efad3981e6214d584fc861933f672cc 100644 GIT binary patch literal 159460 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�{@t^@L9jpPn%_5X7xw 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 literal 160800 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

    ^+#*Ex%FDQ;^cL2 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 delta 213 zcmZoDX(*Y{!SZ8){I!ie84|2a3=9m6lYdGmLRg#SB^CJvPV9J{I@O-leJyJS4-4bt z*3AJLe5^u7dIoxidIk&(T#O8?V9w?OT>&PR$NUV7C%5Pa2xPN_FWn={>^hTm2~R`X zwx-EX^z#*YzNo<&BX@dOuS%c0G$G+H%~WWWa2bp&|@$F YV#Cb~O%5|6xRZ0uv=O|`3(ex00b77N$p8QV diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.dll index dd6482f987b65a516c864227032bd548d6591b32..065ac7c5d88eebdf93200348eb89aa59af719d44 100644 GIT binary patch literal 668672 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%0AT;a0Y(6@@8JL= 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 J_X7S}_9u*y6~q7l literal 0 HcmV?d00001 diff --git a/tests/vectors/unique-index/distinct-values-store.cc b/tests/vectors/unique-index/distinct-values-store.cc new file mode 100644 index 0000000000000000000000000000000000000000..6eabdd96ab0be41daf96302fad28958fbb54ddfa GIT binary patch literal 1549 zcmchVKX2497{;jt{TwAL*T}IQUz`mwurUzCf)H}-*Vm{?4q^v!LLK-BJu0KKp;iM;sD+q!tt z!lS}N^=Ykw#1)Sx#)45yP_76kJeM({B9WSdvgjR)p|C;?ArdiSb#BFwTFP}EF+3r^ zZ&c3%hs{*#j;bI?gEd?*I1}85#uelGPa2GbvI?pkH9`6SJ z+gul%93ISe(_b@m*(9geDbCzBiE!N`B<)s6mfwwT_x0-ZRd&3<(tmaVe~EP+mi3Cg rHhrCKbzI&z_siRk8~4iHoX+6eiw-RM2LJf{u>%i#1>*~EWye1OZtCKX literal 0 HcmV?d00001 diff --git a/tests/vectors/unique-index/normal-keys-store.cc b/tests/vectors/unique-index/normal-keys-store.cc new file mode 100644 index 0000000000000000000000000000000000000000..27f36b96b5e9d32242c4742293eb37b9f8baf330 GIT binary patch literal 946 zcmcgqJx;?g6b6Zlkd+cSPU6Pd5Ca)R$BV1gih1!cEdSk&@EPF# jJWo^nbF;+5S|%hOluL$dX3#xOb`#(CLAOo zLIT7C30E)NeFtB`b}0&_o_1!w+3)*icDsqE!^qcKsvGI-*7>LinOp|9P%#m}8en$> zP`Qj$NE?k8q9bkGc$JB&V%D&odnwalh%%Zss|Bd&W!SuYww;=kdHXFF5%a2JK2!)S zg)&<+=DwUW2&KLrpo|zOEz4uxpP4FQ%CIK08OMsBN871L{Awf4_04G1jgNRL63D<16FrUWu5}s0^&B<5HAHyF*^#A|> delta 12 TcmbOyw^5w=4uj-I=5^cv8$<)@ From 3deb2704588b23f39ed5a100b73c49ef19c552bf Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 11:48:38 +0000 Subject: [PATCH 24/93] cultcache-py: a unique-index refusal names the least violation of the resulting state; walk refusals.msgpack (#101) --- .../cultcache-py/src/cultcache_py/cache.py | 37 ++++---- .../cultcache-py/tests/test_unique_index.py | 88 +++++++++++++++---- 2 files changed, 92 insertions(+), 33 deletions(-) diff --git a/packages/cultcache-py/src/cultcache_py/cache.py b/packages/cultcache-py/src/cultcache_py/cache.py index badfadb5..9c402968 100644 --- a/packages/cultcache-py/src/cultcache_py/cache.py +++ b/packages/cultcache-py/src/cultcache_py/cache.py @@ -317,7 +317,7 @@ class CultCache: structures, running every check and every user extractor; the cache's state is replaced only once that build finishes, so a refused load admits nothing.""" loaded_values: dict[str, dict[str, Any]] = {} - read_order: dict[tuple[str, str], Any] = {} # (type, key) -> value, in the order the stores yielded them + read_values: dict[tuple[str, str], Any] = {} # (type, key) -> value, one per key, the last read wins; the judge is order-free loaded_envelopes: dict[str, dict[str, CultCacheEnvelope]] = {} loaded_legacy_global_keys: dict[str, str] = {} routes, generic = self._state.stores_by_type, self._state.generic_store @@ -356,10 +356,10 @@ class CultCache: envelope = self._stamped(document, envelope) value = document.decode_payload(envelope.payload) loaded_values.setdefault(envelope.type, {})[envelope.key] = value - read_order[(envelope.type, envelope.key)] = value + read_values[(envelope.type, envelope.key)] = value loaded_envelopes.setdefault(envelope.type, {})[envelope.key] = envelope loaded_names, loaded_indexes, loaded_lookup_keys = self._derive_lookups( - [(type, key, value) for (type, key), value in read_order.items()], self._state.name_extractors, self._state.index_extractors + [(type, key, value) for (type, key), value in read_values.items()], self._state.name_extractors, self._state.index_extractors ) # Plain attribute assignments: nothing fallible runs once the swap begins. ( @@ -627,7 +627,7 @@ class CultCache: name_extractors: dict[str, str | Any], index_extractors: dict[str, dict[str, str | Any]], ) -> tuple[_Names, _Indexes, _LookupKeysByType]: - """Builds name and index lookups for the given (type, key, value) records, judged in the order given, in fresh dicts, touching no cache state.""" + """Builds name and index lookups for the given (type, key, value) records, judged whole and order-free, in fresh dicts, touching no cache state.""" names: _Names = {} indexes: _Indexes = {} lookup_keys: _LookupKeysByType = {} @@ -636,7 +636,7 @@ class CultCache: keys = self._lookup_keys_for(value, name_extractors.get(type), index_extractors.get(type, {})) lookup_keys.setdefault(type, {})[key] = keys landing.append((type, key, keys)) - # Judged whole, over every type at once and on the file alone (never against what the cache held): a held value can + # Judged whole and order-free, over every type at once and on the file alone (never against what the cache held): a held value can # have been mutated in place since it was admitted, and another process may have moved a value between records. self._refuse_index_sharing(landing, {}, ()) for type in lookup_keys: @@ -653,28 +653,35 @@ class CultCache: held: dict[tuple[str, str, str], str], replaced: Iterable[tuple[str, str]], ) -> None: - """The one index rule (variants:ruling:unique-indexes), in the order of C# RefuseIndexSharing: within one type and one - unique alias, no two records hold one non-blank value, judged on the result. The holders of a value are the landing - keys projecting it (in landing order) plus the held key unless this admission replaces it; two distinct holders - refuse, naming the two smallest keys (UTF-16 code-unit order) and the alias, never the value. Aliases are judged in - UTF-16 order, values in the order they landed.""" + """The one index rule (variants:ruling:unique-indexes): within one type and one unique alias, no two records hold one + non-blank value, judged on the state the admission would leave and never on staging, file or pull order. The holders + of a value are the landing keys projecting it plus the held key unless this admission replaces it. Of every + (type, alias, value) with two or more distinct holders, the refusal names the least by (alias, its least key, its + second-least key, schema name), each compared by UTF-16 code unit, never the value.""" released = set(replaced) landed: dict[tuple[str, str, str], list[str]] = {} for type, key, keys in landing: for index, index_value in keys.indexes.items(): landed.setdefault((type, index, index_value), []).append(key) - for (type, index, index_value), keys in sorted(landed.items(), key=lambda item: _utf16_order(item[0][1])): + violations: list[tuple[bytes, bytes, bytes, bytes, str, str, str, str]] = [] + for (type, index, index_value), keys in landed.items(): held_key = held.get((type, index, index_value)) if held_key is not None and (type, held_key) not in released: keys = [*keys, held_key] - distinct = sorted(dict.fromkeys(keys), key=_utf16_order) + distinct = sorted(keys, key=_utf16_order) if len(distinct) > 1: document = self._state.documents.get(type) schema_name = (document.schema_name if document is not None else None) or type - raise CultCacheError( - f"Records {distinct[0]} and {distinct[1]} ({schema_name}) both hold unique index '{index}'; " - "an index value names one record." + violations.append( + (_utf16_order(index), _utf16_order(distinct[0]), _utf16_order(distinct[1]), _utf16_order(schema_name), + index, distinct[0], distinct[1], schema_name) ) + if violations: + *_, index, first, second, schema_name = min(violations, key=lambda violation: violation[:4]) + raise CultCacheError( + f"Records {first} and {second} ({schema_name}) both hold unique index '{index}'; " + "an index value names one record." + ) def _remove_lookups(self, type: str, key: str) -> None: """Removes the lookups the held record produced at admission, handing a name to another held diff --git a/packages/cultcache-py/tests/test_unique_index.py b/packages/cultcache-py/tests/test_unique_index.py index 2d988e78..9c19999d 100644 --- a/packages/cultcache-py/tests/test_unique_index.py +++ b/packages/cultcache-py/tests/test_unique_index.py @@ -470,6 +470,74 @@ class VectorTests(unittest.TestCase): self.assertEqual(path.read_bytes(), before) + def test_refusals(self) -> None: + ids = { + "tests.unique_item": "sha256:93bb890de83069061af696b23f94fd32fb3c2dad3c91ee645e8add56085f1882", + "tests.uq_pair": "sha256:2538cceafb6dcbce22ef11b932ecb804af5f96bc3aaedff692a52d3640433e60", + "tests.uq_alias": "sha256:77a77a394ebd8bf71e14b9b6f2cea4978c25d4e705fd12364f660edbf6c63b1f", + "tests.uq_alpha": "sha256:53aeaf2ea9b25e85ade6b15fc04c7653e7b206c87e258f1aa74dd32a840a1389", + "tests.uq_beta": "sha256:45ed62a64c91f6d815d3ec975c4a4a1b2ce07b6e7262a32d828673cf10899586", + } + aliases = { + "tests.uq_pair": {"Sku": "sku", "Alt": "second"}, + "tests.uq_alias": {"Sku": "sku", "alt": "second"}, + } + documents = { + name: define_database_entry_type( + name, + [database_entry_field("sku", 0, codec=t.string), database_entry_field("second", 1, codec=t.string)], + indexes=aliases.get(name, {"Sku": "sku"}), + roles={}, + schema_id=schema_id, + schema_name=name, + ) + for name, schema_id in ids.items() + } + rows = msgpack.unpackb((VECTORS / "refusals.msgpack").read_bytes(), raw=False) + walked = 0 + for label, ops, expected in rows: + if any(op[0] == "commit" and len({schema for _, schema, _, _ in op[1]}) > 1 for op in ops): + continue # put_envelopes takes one type: no batch across types (rows 3 and 26) + with self.subTest(label=label), tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "store.cc" + state: dict[str, CultCache] = {} + + def perform(op) -> None: + if op[0] == "open": + if op[1] is not None: + shutil.copyfile(VECTORS / op[1], path) + state["cache"] = cache_of(*documents.values(), store=SingleFileMessagePackBackingStore(path)) + state["cache"].pull_all_backing_stores() + elif op[0] == "pull": + shutil.copyfile(VECTORS / op[1], path) + state["cache"].pull_all_backing_stores() + else: + document = documents[op[1][0][1]] + state["cache"].put_envelopes( + document, [envelope(document, key, {"sku": sku, "second": second}) for key, _, sku, second in op[1]] + ) + + for op in ops[:-1]: + perform(op) + if expected is None: + perform(ops[-1]) + else: + last = ops[-1] + if last[0] == "open": + before = (VECTORS / last[1]).read_bytes() + elif last[0] == "pull": + before = (VECTORS / last[1]).read_bytes() + else: + before = path.read_bytes() if path.exists() else b"" + k1, k2, schema, alias = expected + with self.assertRaises(CultCacheError) as caught: + perform(last) + self.assertEqual(str(caught.exception), refusal(k1, k2, schema, alias)) + self.assertEqual(path.read_bytes() if path.exists() else b"", before) + walked += 1 + self.assertEqual(walked, 31) + + class ListStore(RecordingStore): """A store whose contents the test swaps between loads: another process rewrote the file.""" @@ -515,22 +583,6 @@ class BatchAndOrderTests(unittest.TestCase): ) self.assertNotIn("item:j", cache.snapshot().get("item", {})) - def test_values_are_named_in_landing_order(self) -> None: - document = item_doc() - cache = cache_of(document) - rows = [("item:a", "B"), ("item:b", "B"), ("item:x", "A"), ("item:y", "A")] - with self.assertRaises(CultCacheError) as caught: - cache.put_envelopes(document, [envelope(document, key, Item(sku=sku)) for key, sku in rows]) - self.assertEqual(str(caught.exception), refusal("item:a", "item:b", "item", "sku")) - - def test_keys_sort_by_utf16_code_unit_not_code_point(self) -> None: - document = item_doc() - cache = cache_of(document) - astral, bmp = "k\U0001F600", "k\uff01" # code points: bmp < astral; UTF-16 units: D83D < FF01 - with self.assertRaises(CultCacheError) as caught: - cache.put_envelopes(document, [envelope(document, bmp, Item(sku="S")), envelope(document, astral, Item(sku="S"))]) - self.assertEqual(str(caught.exception), refusal(astral, bmp, "item", "sku")) - def test_a_load_judges_every_type_at_once_aliases_in_ordinal_order(self) -> None: first = item_doc("first", indexes={"zeta": "sku"}, roles={}) second = item_doc("second", indexes={"alpha": "sku"}, roles={}) @@ -553,7 +605,7 @@ class BatchAndOrderTests(unittest.TestCase): cache.put_envelopes(document, [envelope(document, f"item:{n}", row) for n, row in enumerate(rows)]) self.assertEqual(str(caught.exception), refusal("item:0", "item:1", "item", astral)) - def test_a_load_judges_records_in_the_order_the_file_yields_them(self) -> None: + def test_a_load_names_the_least_violation_whatever_order_the_store_yields(self) -> None: alpha, beta = item_doc("alpha", indexes={"k": "sku"}, roles={}), item_doc("beta", indexes={"k": "sku"}, roles={}) loose_alpha, loose_beta = item_doc("alpha", indexes={}, roles={}), item_doc("beta", indexes={}, roles={}) store = ListStore( @@ -568,7 +620,7 @@ class BatchAndOrderTests(unittest.TestCase): cache = cache_of(alpha, beta, store=store) with self.assertRaises(CultCacheError) as caught: cache.pull_all_backing_stores() - self.assertEqual(str(caught.exception), refusal("b:1", "b:2", "beta", "k")) + self.assertEqual(str(caught.exception), refusal("a:1", "a:2", "alpha", "k")) def test_register_index_refuses_an_alias_the_definition_declares_as_a_role(self) -> None: document = item_doc() From 1211e8c6dc1195c5223828ee40b14def2de7814f Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 12:00:00 +0000 Subject: [PATCH 25/93] cultcache-ts: a load and an index registration name the least violation of the resulting state; walk refusals.msgpack (#103) --- packages/cultcache-ts/src/cult-cache.ts | 61 ++++++++--- .../cultcache-ts/test/unique-index.test.ts | 103 ++++++++++++------ 2 files changed, 117 insertions(+), 47 deletions(-) diff --git a/packages/cultcache-ts/src/cult-cache.ts b/packages/cultcache-ts/src/cult-cache.ts index fde76001..c5bb663d 100644 --- a/packages/cultcache-ts/src/cult-cache.ts +++ b/packages/cultcache-ts/src/cult-cache.ts @@ -47,6 +47,7 @@ type EntryLookups = { }; type HydratedEntry = CultCacheEnvelope & { + catalogEntry: NonNullable; value: unknown; lookups: EntryLookups; }; @@ -316,6 +317,16 @@ export class CultCache { } } + // Judged whole, once: the refusal names the least violation of the state this load would leave, whatever the + // order of the stores and of the entries in them. + refuseLeastIndexSharing(function* () { + for (const entry of next.entries.values()) { + for (const [alias, value] of entry.lookups.indexes) { + yield { type: entry.type, schemaName: entry.catalogEntry.schemaName, alias, value, key: entry.key }; + } + } + }()); + this.#hydrated = next; }); } @@ -652,6 +663,7 @@ export class CultCache { async #writeNow(registered: RegisteredDefinition, entry: CultCacheEnvelope, value: unknown): Promise { const home = this.#homeStore(entry.type); const hydrated = this.#admitEntry(this.#hydrated, registered, entry, value); + this.#refuseIndexSharing(this.#hydrated, entry.type, registered.catalogEntry.schemaName, entry.key, hydrated.lookups); const legacy = this.#hydrated.legacyGlobals.get(entry.type); await home?.push(entry); if (home && legacy) { @@ -903,7 +915,6 @@ export class CultCache { } const lookups = this.#lookupsFor(registered.nameAccessor, registered.indexAccessors, value); - this.#refuseIndexSharing(state, entry.type, registered.catalogEntry.schemaName, entry.key, lookups); return { ...entry, payload: this.#cloneBytes(entry.payload), @@ -995,7 +1006,6 @@ export class CultCache { const entryLookups: Array<[HydratedEntry, EntryLookups]> = []; const nameMap = nameAccessor ? new Map() : undefined; const indexMaps = new Map>(); - const sharing = new Map(); for (const indexName of indexAccessors.keys()) { indexMaps.set(indexName, new Map()); } @@ -1008,21 +1018,16 @@ export class CultCache { } for (const [indexName, indexValue] of lookups.indexes) { const map = indexMaps.get(indexName)!; - const holder = map.get(indexValue); - if (holder === undefined) { - map.set(indexValue, entry.key); - } else { - sharing.set(`${indexName}\u0000${indexValue}`, [...(sharing.get(`${indexName}\u0000${indexValue}`) ?? [holder]), entry.key]); - } + if (!map.has(indexValue)) map.set(indexValue, entry.key); } } - // Judged whole: any value held by two records refuses, naming the two smallest of its keys. Held records already - // passed the judge for every alias installed before, so only the alias being registered can be shared here. - for (const [id, keys] of sharing) { - keys.sort(compareOrdinal); - throw indexSharingRefusal(keys[0], keys[1], schemaName, id.slice(0, id.indexOf("\u0000"))); - } + // Judged whole, by the same judge as a load: the least violation of the state the registration would leave. + refuseLeastIndexSharing(function* () { + for (const [entry, lookups] of entryLookups) { + for (const [alias, value] of lookups.indexes) yield { type, schemaName, alias, value, key: entry.key }; + } + }()); return { entryLookups, nameMap, indexMaps }; } @@ -1099,6 +1104,34 @@ function compareOrdinal(a: string, b: string): number { return a < b ? -1 : a > b ? 1 : 0; } +// The whole-state judge (variants:question:unique-index-refusal-pair-rule): over every (type, alias, value) held at two +// or more distinct keys, throws for the one least by (alias, least key, second-least key, schema name), each compared by +// UTF-16 code unit. A function of the state, never of the order the rows arrive in. +function refuseLeastIndexSharing(rows: Iterable<{ type: string; schemaName: string; alias: string; value: string; key: string }>): void { + const groups = new Map(); + for (const { type, schemaName, alias, value, key } of rows) { + const id = JSON.stringify([type, alias, value]); + const group = groups.get(id); + if (group) group.keys.push(key); + else groups.set(id, { schemaName, alias, keys: [key] }); + } + let least: { schemaName: string; alias: string; first: string; second: string } | undefined; + for (const { schemaName, alias, keys } of groups.values()) { + if (keys.length < 2) continue; + keys.sort(compareOrdinal); + const candidate = { schemaName, alias, first: keys[0], second: keys[1] }; + if (!least || compareLeast(candidate, least) < 0) least = candidate; + } + if (least) throw indexSharingRefusal(least.first, least.second, least.schemaName, least.alias); +} + +function compareLeast( + a: { schemaName: string; alias: string; first: string; second: string }, + b: { schemaName: string; alias: string; first: string; second: string }, +): number { + return compareOrdinal(a.alias, b.alias) || compareOrdinal(a.first, b.first) || compareOrdinal(a.second, b.second) || compareOrdinal(a.schemaName, b.schemaName); +} + function indexSharingRefusal(keyA: string, keyB: string, schemaName: string, alias: string): Error { const [first, second] = compareOrdinal(keyA, keyB) <= 0 ? [keyA, keyB] : [keyB, keyA]; return new Error(`Records ${first} and ${second} (${schemaName}) both hold unique index '${alias}'; an index value names one record.`); diff --git a/packages/cultcache-ts/test/unique-index.test.ts b/packages/cultcache-ts/test/unique-index.test.ts index 852ea08b..ae296094 100644 --- a/packages/cultcache-ts/test/unique-index.test.ts +++ b/packages/cultcache-ts/test/unique-index.test.ts @@ -119,14 +119,6 @@ test("a removed or changed holder releases its value", async () => { assert.equal(cache.getKeyByIndex(document, "sku", "T"), "a"); }); -test("the keys in a refusal are in ordinal order whichever one lands second", async () => { - const document = item("item"); - const cache = memory(document); - await cache.put(document, "m", { sku: "S" }); - await assert.rejects(cache.put(document, "z", { sku: "S" }), { message: refusal("m", "z", "tests.item", "sku") }); - await assert.rejects(cache.put(document, "B", { sku: "S" }), { message: refusal("B", "m", "tests.item", "sku") }); -}); - // The C# White_Space set, and nothing else, is blank. Every UTF-16 code unit is judged, so a rule written for a few // characters (or for JavaScript's \s, which counts U+FEFF and omits U+0085) cannot pass. const CSHARP_WHITE_SPACE = (code: number) => @@ -365,6 +357,76 @@ test("vectors duplicate-store.cc and blank-store.cc hold their verdicts", async } }); +// refusals.msgpack: rows [label, ops, refusal] written by the C# reference. TypeScript has no batch commit +// (variants:idea:unique-index-runtimes.ts-batch-commit), so it performs every row whose commits each hold one record: +// 5, 8-13, 19-22, 24, 25, 27, 29, 31, 32 and 33 of the README (18). +const REFUSAL_SCHEMAS: [string, string, string, Record][] = [ + ["unique_item", "tests.unique_item", "sha256:93bb890de83069061af696b23f94fd32fb3c2dad3c91ee645e8add56085f1882", { Sku: "sku" }], + ["uq_pair", "tests.uq_pair", "sha256:2538cceafb6dcbce22ef11b932ecb804af5f96bc3aaedff692a52d3640433e60", { Sku: "sku", Alt: "second" }], + ["uq_alias", "tests.uq_alias", "sha256:77a77a394ebd8bf71e14b9b6f2cea4978c25d4e705fd12364f660edbf6c63b1f", { Sku: "sku", alt: "second" }], + ["uq_alpha", "tests.uq_alpha", "sha256:53aeaf2ea9b25e85ade6b15fc04c7653e7b206c87e258f1aa74dd32a840a1389", { Sku: "sku" }], + ["uq_beta", "tests.uq_beta", "sha256:45ed62a64c91f6d815d3ec975c4a4a1b2ce07b6e7262a32d828673cf10899586", { Sku: "sku" }], +]; + +type RefusalOp = ["open", string | null] | ["pull", string] | ["commit", [string, string, string, string][]]; + +test("vectors refusals.msgpack: every row TS can perform", async () => { + const rows = decode(await readFile(join(vectors, "refusals.msgpack"))) as [string, RefusalOp[], [string, string, string, string] | null][]; + const definitions = new Map( + REFUSAL_SCHEMAS.map(([type, schemaName, schemaId, indexes]) => [ + schemaName, + defineDocumentType({ + type, + schemaId, + schemaName, + schemaVersion: `${schemaName}.v1`, + schema: z.object({ sku: z.string(), second: z.string() }), + codec: codecs.slots<{ sku: string; second: string }>({ 0: ["sku", codecs.string], 1: ["second", codecs.string] }, `Uq${type}`), + indexes, + }), + ]), + ); + const dir = await mkdtemp(join(tmpdir(), "uidx-refusals-")); + let walked = 0; + try { + for (const [label, ops, refused] of rows) { + if (ops.some((op) => op[0] === "commit" && op[1].length > 1)) continue; + const file = join(dir, `${walked}.cc`); + let cache: CultCache | undefined; + const perform = async (op: RefusalOp) => { + if (op[0] === "open") { + if (op[1] !== null) await copyFile(join(vectors, op[1]), file); + let builder = CultCache.builder().withGenericStore(new SingleFileMessagePackBackingStore(file)); + for (const definition of definitions.values()) builder = builder.withDocumentType(definition); + cache = builder.build(); + await cache.pullAllBackingStores(); + } else if (op[0] === "pull") { + await copyFile(join(vectors, op[1]), file); + await cache!.pullAllBackingStores(); + } else { + for (const [key, schema, sku, second] of op[1]) await cache!.put(definitions.get(schema)!, key, { sku, second }); + } + }; + for (const op of ops.slice(0, -1)) await perform(op); + const last = ops[ops.length - 1]; + if (refused === null) { + await perform(last); + } else { + const [k1, k2, schema, alias] = refused; + const before = last[0] === "commit" || (last[0] === "open" && last[1] === null) + ? (existsSync(file) ? await readFile(file) : Buffer.alloc(0)) + : await readFile(join(vectors, last[1]!)); + await assert.rejects(perform(last), { message: refusal(k1, k2, schema, alias) }, label); + assert.deepEqual(existsSync(file) ? await readFile(file) : Buffer.alloc(0), before, `${label}: the file is untouched`); + } + walked++; + } + } finally { + await rm(dir, { recursive: true, force: true }); + } + assert.equal(walked, 18); +}); + test("a refused load leaves the cache's prior state as it was, and a later good load still works", async () => { const dir = await mkdtemp(join(tmpdir(), "uidx-load-")); try { @@ -487,28 +549,3 @@ test("registerIndex of a second unique alias judges it, naming the two smallest assert.equal(cache.getKeyByIndex(document, "a", "1"), "z", "the alias already installed is intact"); assert.equal(cache.getKeyByIndex(document, "b", "s"), undefined); }); - -test("verdict parity: the same stores are refused whatever the load order, and the first collision refuses (text is informational)", async () => { - const dir = await mkdtemp(join(tmpdir(), "uidx-verdict-")); - try { - // Three records, one pair colliding; whichever position the pair has in the file, the store is refused and a clean store opens. - const roles = pair({ roles: { a: "a" } }); - for (const [name, values, refused] of [ - ["pair-first", ["s", "s", "t"], true], - ["pair-last", ["t", "s", "s"], true], - ["pair-split", ["s", "t", "s"], true], - ["distinct", ["r", "s", "t"], false], - ] as const) { - const file = join(dir, `${name}.cc`); - const writer = CultCache.builder().withDocumentType(roles).withGenericStore(new SingleFileMessagePackBackingStore(file)).build(); - for (const [i, a] of values.entries()) await writer.put(roles, `r${i}`, { a }); - const unique = pair({ indexes: { a: "a" } }); - const reader = CultCache.builder().withDocumentType(unique).withGenericStore(new SingleFileMessagePackBackingStore(file)).build(); - if (refused) await assert.rejects(reader.pullAllBackingStores(), /both hold unique index 'a'/u, name); - else await reader.pullAllBackingStores(); - assert.equal(reader.snapshot().length, refused ? 0 : 3, name); - } - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); From 17caa0c87409482f90486585643220fb6b74eb26 Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 12:16:57 +0000 Subject: [PATCH 26/93] cultcache-rs: judge_against names the least violation by UTF-16 order; walk refusals.msgpack (#102) --- packages/cultcache-rs/src/lib.rs | 46 ++++-- packages/cultcache-rs/tests/unique_index.rs | 153 ++++++++++++++++---- 2 files changed, 159 insertions(+), 40 deletions(-) diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index 11d21330..e5e88b9c 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -2738,6 +2738,16 @@ pub struct CultCache { } type IndexProjector = fn(&[u8]) -> Result>; +/// Orders by UTF-16 code unit (C# `StringComparer.Ordinal`), not by UTF-8 bytes or code point. +fn utf16_cmp(left: &str, right: &str) -> std::cmp::Ordering { + left.encode_utf16().cmp(right.encode_utf16()) +} + +/// Orders violations (alias, least key, second key, schema) field by field, each by UTF-16. +fn violation_cmp(left: &[&str; 4], right: &[&str; 4]) -> std::cmp::Ordering { + left.iter().zip(right).map(|(a, b)| utf16_cmp(a, b)).find(|order| order.is_ne()).unwrap_or(std::cmp::Ordering::Equal) +} + type IndexSlot = (String, String, String); type IndexHolders = BTreeMap; @@ -3424,22 +3434,36 @@ impl CultCache { groups[index].1.insert(key); } } - groups.sort_by(|left, right| left.0.1.cmp(&right.0.1)); - for (slot, mut holders) in groups { - if let Some(held_key) = held.get(&slot) + let mut least: Option<[&str; 4]> = None; + for (slot, holders) in &mut groups { + if let Some(held_key) = held.get(&*slot) && !last.contains_key(&(slot.0.as_str(), held_key.as_str())) { holders.insert(held_key.clone()); } - let mut ordered = holders.iter(); - if let (Some(first), Some(second)) = (ordered.next(), ordered.next()) { - return Err(anyhow!( - "Records {first} and {second} ({}) both hold unique index '{}'; an index value names one record.", - self.definitions.get(&slot.0).copied().unwrap_or(&slot.0), - slot.1 - )); + } + let mut ordered: Vec<(&IndexSlot, Vec<&String>)> = Vec::new(); + for (slot, holders) in &groups { + let mut keys: Vec<&String> = holders.iter().collect(); + keys.sort_by(|left, right| utf16_cmp(left, right)); + ordered.push((slot, keys)); + } + for (slot, keys) in &ordered { + if let [first, second, ..] = keys.as_slice() { + let schema = self.definitions.get(&slot.0).copied().unwrap_or(&slot.0); + let violation = [slot.1.as_str(), first.as_str(), second.as_str(), schema]; + if least.is_none_or(|best| violation_cmp(&violation, &best).is_lt()) { + least = Some(violation); + } } - let key = holders.into_iter().next().expect("a group holds at least one key"); + } + if let Some([alias, first, second, schema]) = least { + return Err(anyhow!( + "Records {first} and {second} ({schema}) both hold unique index '{alias}'; an index value names one record." + )); + } + for (slot, mut keys) in ordered.into_iter().map(|(slot, keys)| (slot.clone(), keys)) { + let key = keys.remove(0).clone(); change.taken.push((slot, key)); } Ok(change) diff --git a/packages/cultcache-rs/tests/unique_index.rs b/packages/cultcache-rs/tests/unique_index.rs index d0da4175..1fc3c4eb 100644 --- a/packages/cultcache-rs/tests/unique_index.rs +++ b/packages/cultcache-rs/tests/unique_index.rs @@ -80,6 +80,41 @@ struct VBool { v: bool, } +/// The refusals vector's other schemas: `Sku` is unique in each; `Alt` is a second unique index in `uq_pair` (alias +/// `Alt`) and `uq_alias` (alias `alt`, sorting after `Sku` only under an ordinal compare); alpha and beta are plain. +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "uq_pair", schema = "tests.uq_pair")] +struct UqPair { + #[cultcache(key = 0, index = "Sku")] + sku: String, + #[cultcache(key = 1, index = "Alt")] + alt: String, +} +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "uq_alias", schema = "tests.uq_alias")] +struct UqAlias { + #[cultcache(key = 0, index = "Sku")] + sku: String, + #[cultcache(key = 1, index = "alt")] + alt: String, +} +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "uq_alpha", schema = "tests.uq_alpha")] +struct UqAlpha { + #[cultcache(key = 0, index = "Sku")] + sku: String, + #[cultcache(key = 1)] + second: String, +} +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "uq_beta", schema = "tests.uq_beta")] +struct UqBeta { + #[cultcache(key = 0, index = "Sku")] + sku: String, + #[cultcache(key = 1)] + second: String, +} + fn unique(sku: &str) -> Unique { Unique { sku: sku.into(), category: "c".into() } } @@ -339,35 +374,6 @@ fn a_batch_is_judged_whole() { assert_eq!(cache.get_by_index::("Sku", "D1").unwrap(), cache.get::("e").unwrap()); } -// The verdict and the named pair do not depend on key order, staging order, or alias declaration order. -#[test] -fn the_refusal_names_the_ordinal_pair_whatever_the_order() { - let orders: [[&str; 3]; 6] = - [["a", "b", "c"], ["a", "c", "b"], ["b", "a", "c"], ["b", "c", "a"], ["c", "a", "b"], ["c", "b", "a"]]; - for order in orders { - let mut cache = memory_cache(); - let rows: Vec<_> = order.iter().map(|key| (*key, unique("S"))).collect(); - let text = error_text(cache.put_prepared_batch(staged(&cache, &rows))); - assert_eq!(text, refusal("a", "b", "tests.unique_item", "Sku"), "{order:?}"); - assert!(skus(&cache).is_empty()); - } - // Both aliases collide in one batch: the alias sorting first is named, though `B` is declared first. - for order in [["x", "y"], ["y", "x"]] { - let mut cache = memory_cache(); - let rows: Vec<_> = order.iter().map(|key| (*key, multi(Some("s"), Some("t")))).collect(); - let text = error_text(cache.put_prepared_batch(staged(&cache, &rows))); - assert_eq!(text, refusal("x", "y", "Multi", "A")); - } - // A held key and a landing key: the pair is ordinal, not held-first. - let mut cache = memory_cache(); - cache.put("m", &unique("S")).unwrap(); - assert_eq!(error_text(cache.put("z", &unique("S"))), refusal("m", "z", "tests.unique_item", "Sku")); - assert_eq!(error_text(cache.put("a", &unique("S"))), refusal("a", "m", "tests.unique_item", "Sku")); - // A value that differs only by case or by a surrounding space is another value. - cache.put("n", &unique("s")).unwrap(); - cache.put("o", &unique("S ")).unwrap(); -} - #[test] fn a_load_of_a_duplicate_image_is_refused_and_keeps_the_prior_view() { let dir = tempfile::tempdir().unwrap(); @@ -561,6 +567,95 @@ fn vectors_stores_hold_their_verdicts() { } } +/// One op of a refusals row: `["open", file-or-nil]`, `["pull", file]` or `["commit", [[key, schema, sku, second], ...]]`. +#[derive(Deserialize)] +#[serde(untagged)] +enum Arg { + Records(Vec<(String, String, String, String)>), + File(Option), +} + +type RefusalRow = (String, Vec<(String, Arg)>, Option<(String, String, String, String)>); + +/// Rows `[label, ops, refusal]`: the pair a refusal names is a function of the state the admission would leave +/// (`tests/vectors/unique-index/README.md`, RULE), whatever the staging, file or pull order, key case or alias case. +#[test] +fn vectors_refusals_hold_the_rule() { + let bytes = std::fs::read(vectors().join("refusals.msgpack")).unwrap(); + let rows: Vec = rmp_serde::from_slice(&bytes).unwrap(); + let dir = tempfile::tempdir().unwrap(); + let mut walked = 0; + for (index, (label, ops, refused)) in rows.into_iter().enumerate() { + let path = dir.path().join(format!("row-{index}.cc")); + let mut cache: Option = None; + let mut failure = None; + for (at, (kind, arg)) in ops.iter().enumerate() { + let last = at + 1 == ops.len(); + let mut before = path.exists().then(|| std::fs::read(&path).unwrap()); + let result = match (kind.as_str(), arg) { + ("open", Arg::File(file)) => { + if let Some(file) = file { + std::fs::copy(vectors().join(file), &path).unwrap(); + } + let mut opened = CultCache::new(); + opened.register_entry_type::().unwrap(); + opened.register_entry_type::().unwrap(); + opened.register_entry_type::().unwrap(); + opened.register_entry_type::().unwrap(); + opened.register_entry_type::().unwrap(); + opened.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap(); + let pulled = opened.pull_all_backing_stores(); + cache = Some(opened); + pulled + } + ("pull", Arg::File(Some(file))) => { + std::fs::copy(vectors().join(file), &path).unwrap(); + before = Some(std::fs::read(&path).unwrap()); + cache.as_mut().unwrap().pull_all_backing_stores() + } + ("commit", Arg::Records(records)) => { + let cache = cache.as_mut().unwrap(); + let staged: Vec<_> = records + .iter() + .map(|(key, schema, sku, second)| { + let (sku, second) = (sku.clone(), second.clone()); + match schema.as_str() { + "tests.unique_item" => cache.prepare_entry(key, &Unique { sku, category: second }).map(|(envelope, _)| envelope), + "tests.uq_pair" => cache.prepare_entry(key, &UqPair { sku, alt: second }).map(|(envelope, _)| envelope), + "tests.uq_alias" => cache.prepare_entry(key, &UqAlias { sku, alt: second }).map(|(envelope, _)| envelope), + "tests.uq_alpha" => cache.prepare_entry(key, &UqAlpha { sku, second }).map(|(envelope, _)| envelope), + "tests.uq_beta" => cache.prepare_entry(key, &UqBeta { sku, second }).map(|(envelope, _)| envelope), + other => panic!("{label}: unexpected schema {other}"), + } + .unwrap() + }) + .collect(); + cache.put_prepared_batch(staged) + } + other => panic!("{label}: unexpected op {:?}", other.0), + }; + if last { + failure = Some((result, before)); + } else { + result.unwrap_or_else(|error| panic!("{label}: op {at} should succeed: {error:#}")); + } + } + let (result, before) = failure.unwrap(); + match refused { + None => result.unwrap_or_else(|error| panic!("{label}: should succeed: {error:#}")), + Some((k1, k2, schema, alias)) => { + let error = result.err().unwrap_or_else(|| panic!("{label}: should refuse")); + assert_eq!(format!("{error:#}"), refusal(&k1, &k2, &schema, &alias), "{label}"); + if let Some(before) = before { + assert_eq!(std::fs::read(&path).unwrap(), before, "{label}: the file is byte-identical"); + } + } + } + walked += 1; + } + assert_eq!(walked, 33); +} + // A pull replaces the holder table with the stored image's: a value another writer changed is released, not kept. #[test] fn a_second_pull_replaces_the_holders_with_the_stored_image() { From 7c2e514941f7169a225b4a92277cad3f8e250553 Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 13:41:10 +0000 Subject: [PATCH 27/93] Unique-index vector: three-holder normal-form and astral-alias rows 34-37; walk counts raised in all four runtimes (#107) --- .../cultcache-py/tests/test_unique_index.py | 4 ++- packages/cultcache-rs/tests/unique_index.rs | 13 +++++++++- .../cultcache-ts/test/unique-index.test.ts | 5 ++-- .../UniqueIndexVectorTests.cs | 23 ++++++++++++++++-- tests/vectors/unique-index/README.md | 22 ++++++++++------- .../unique-index/normal-three-store.cc | Bin 0 -> 1063 bytes tests/vectors/unique-index/refusals.msgpack | Bin 3870 -> 4317 bytes tests/vectors/unique-index/wide-store.cc | Bin 0 -> 935 bytes 8 files changed, 52 insertions(+), 15 deletions(-) create mode 100644 tests/vectors/unique-index/normal-three-store.cc create mode 100644 tests/vectors/unique-index/wide-store.cc diff --git a/packages/cultcache-py/tests/test_unique_index.py b/packages/cultcache-py/tests/test_unique_index.py index 9c19999d..accc27dd 100644 --- a/packages/cultcache-py/tests/test_unique_index.py +++ b/packages/cultcache-py/tests/test_unique_index.py @@ -475,12 +475,14 @@ class VectorTests(unittest.TestCase): "tests.unique_item": "sha256:93bb890de83069061af696b23f94fd32fb3c2dad3c91ee645e8add56085f1882", "tests.uq_pair": "sha256:2538cceafb6dcbce22ef11b932ecb804af5f96bc3aaedff692a52d3640433e60", "tests.uq_alias": "sha256:77a77a394ebd8bf71e14b9b6f2cea4978c25d4e705fd12364f660edbf6c63b1f", + "tests.uq_wide": "sha256:b79eecd11eed5e456902f095d2748da7f59125c4c8972b0da7dddc81d07fc03f", "tests.uq_alpha": "sha256:53aeaf2ea9b25e85ade6b15fc04c7653e7b206c87e258f1aa74dd32a840a1389", "tests.uq_beta": "sha256:45ed62a64c91f6d815d3ec975c4a4a1b2ce07b6e7262a32d828673cf10899586", } aliases = { "tests.uq_pair": {"Sku": "sku", "Alt": "second"}, "tests.uq_alias": {"Sku": "sku", "alt": "second"}, + "tests.uq_wide": {"\uff21": "sku", "\U00010400": "second"}, # U+FF21 and U+10400: code-unit order puts the astral alias first } documents = { name: define_database_entry_type( @@ -535,7 +537,7 @@ class VectorTests(unittest.TestCase): self.assertEqual(str(caught.exception), refusal(k1, k2, schema, alias)) self.assertEqual(path.read_bytes() if path.exists() else b"", before) walked += 1 - self.assertEqual(walked, 31) + self.assertEqual(walked, 35) class ListStore(RecordingStore): diff --git a/packages/cultcache-rs/tests/unique_index.rs b/packages/cultcache-rs/tests/unique_index.rs index 1fc3c4eb..1132b2b5 100644 --- a/packages/cultcache-rs/tests/unique_index.rs +++ b/packages/cultcache-rs/tests/unique_index.rs @@ -98,6 +98,15 @@ struct UqAlias { #[cultcache(key = 1, index = "alt")] alt: String, } +/// Aliases U+FF21 and U+10400: UTF-16 puts the astral alias first (D801 DC00 < FF21), code-point order the other way. +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "uq_wide", schema = "tests.uq_wide")] +struct UqWide { + #[cultcache(key = 0, index = "\u{FF21}")] + sku: String, + #[cultcache(key = 1, index = "\u{10400}")] + alt: String, +} #[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] #[cultcache(type = "uq_alpha", schema = "tests.uq_alpha")] struct UqAlpha { @@ -601,6 +610,7 @@ fn vectors_refusals_hold_the_rule() { opened.register_entry_type::().unwrap(); opened.register_entry_type::().unwrap(); opened.register_entry_type::().unwrap(); + opened.register_entry_type::().unwrap(); opened.register_entry_type::().unwrap(); opened.register_entry_type::().unwrap(); opened.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap(); @@ -623,6 +633,7 @@ fn vectors_refusals_hold_the_rule() { "tests.unique_item" => cache.prepare_entry(key, &Unique { sku, category: second }).map(|(envelope, _)| envelope), "tests.uq_pair" => cache.prepare_entry(key, &UqPair { sku, alt: second }).map(|(envelope, _)| envelope), "tests.uq_alias" => cache.prepare_entry(key, &UqAlias { sku, alt: second }).map(|(envelope, _)| envelope), + "tests.uq_wide" => cache.prepare_entry(key, &UqWide { sku, alt: second }).map(|(envelope, _)| envelope), "tests.uq_alpha" => cache.prepare_entry(key, &UqAlpha { sku, second }).map(|(envelope, _)| envelope), "tests.uq_beta" => cache.prepare_entry(key, &UqBeta { sku, second }).map(|(envelope, _)| envelope), other => panic!("{label}: unexpected schema {other}"), @@ -653,7 +664,7 @@ fn vectors_refusals_hold_the_rule() { } walked += 1; } - assert_eq!(walked, 33); + assert_eq!(walked, 37); } // A pull replaces the holder table with the stored image's: a value another writer changed is released, not kept. diff --git a/packages/cultcache-ts/test/unique-index.test.ts b/packages/cultcache-ts/test/unique-index.test.ts index ae296094..955c439a 100644 --- a/packages/cultcache-ts/test/unique-index.test.ts +++ b/packages/cultcache-ts/test/unique-index.test.ts @@ -359,11 +359,12 @@ test("vectors duplicate-store.cc and blank-store.cc hold their verdicts", async // refusals.msgpack: rows [label, ops, refusal] written by the C# reference. TypeScript has no batch commit // (variants:idea:unique-index-runtimes.ts-batch-commit), so it performs every row whose commits each hold one record: -// 5, 8-13, 19-22, 24, 25, 27, 29, 31, 32 and 33 of the README (18). +// 5, 8-13, 19-22, 24, 25, 27, 29, 31, 32, 33, 35 and 37 of the README (20). const REFUSAL_SCHEMAS: [string, string, string, Record][] = [ ["unique_item", "tests.unique_item", "sha256:93bb890de83069061af696b23f94fd32fb3c2dad3c91ee645e8add56085f1882", { Sku: "sku" }], ["uq_pair", "tests.uq_pair", "sha256:2538cceafb6dcbce22ef11b932ecb804af5f96bc3aaedff692a52d3640433e60", { Sku: "sku", Alt: "second" }], ["uq_alias", "tests.uq_alias", "sha256:77a77a394ebd8bf71e14b9b6f2cea4978c25d4e705fd12364f660edbf6c63b1f", { Sku: "sku", alt: "second" }], + ["uq_wide", "tests.uq_wide", "sha256:b79eecd11eed5e456902f095d2748da7f59125c4c8972b0da7dddc81d07fc03f", { "\uFF21": "sku", "\u{10400}": "second" }], ["uq_alpha", "tests.uq_alpha", "sha256:53aeaf2ea9b25e85ade6b15fc04c7653e7b206c87e258f1aa74dd32a840a1389", { Sku: "sku" }], ["uq_beta", "tests.uq_beta", "sha256:45ed62a64c91f6d815d3ec975c4a4a1b2ce07b6e7262a32d828673cf10899586", { Sku: "sku" }], ]; @@ -424,7 +425,7 @@ test("vectors refusals.msgpack: every row TS can perform", async () => { } finally { await rm(dir, { recursive: true, force: true }); } - assert.equal(walked, 18); + assert.equal(walked, 20); }); test("a refused load leaves the cache's prior state as it was, and a later good load still works", async () => { diff --git a/tests/GameCult.Caching.Tests/UniqueIndexVectorTests.cs b/tests/GameCult.Caching.Tests/UniqueIndexVectorTests.cs index 23520310..1fd026ae 100644 --- a/tests/GameCult.Caching.Tests/UniqueIndexVectorTests.cs +++ b/tests/GameCult.Caching.Tests/UniqueIndexVectorTests.cs @@ -316,6 +316,12 @@ namespace GameCult.Caching.Tests EmittedDocumentTypes.Emit("UqVecAliasIndex", "tests.uq_alias", "tests.uq_alias.v1", new EmittedDocumentTypes.Field[] { new("Sku", typeof(string), 0, Index: "Sku"), new("Alt", typeof(string), 1, Index: "alt") }), "Alt"), + new("tests.uq_wide", + EmittedDocumentTypes.Emit("UqVecWideRole", "tests.uq_wide", "tests.uq_wide.v1", + new EmittedDocumentTypes.Field[] { new("Sku", typeof(string), 0, Role: "\uFF21"), new("Alt", typeof(string), 1, Role: "\U00010400") }), + EmittedDocumentTypes.Emit("UqVecWideIndex", "tests.uq_wide", "tests.uq_wide.v1", + new EmittedDocumentTypes.Field[] { new("Sku", typeof(string), 0, Index: "\uFF21"), new("Alt", typeof(string), 1, Index: "\U00010400") }), + "Alt"), Plain("Alpha", "tests.uq_alpha"), Plain("Beta", "tests.uq_beta"), }; @@ -333,6 +339,7 @@ namespace GameCult.Caching.Tests private const string Item = "tests.unique_item"; private const string Pair = "tests.uq_pair"; private const string Alias = "tests.uq_alias"; + private const string Wide = "tests.uq_wide"; private const string Alpha = "tests.uq_alpha"; private const string Beta = "tests.uq_beta"; @@ -357,6 +364,10 @@ namespace GameCult.Caching.Tests private static readonly Rec[] DistinctValueRecords = Items_( ("k:1", "S"), ("k:2", "s"), ("k:3", "S "), ("k:4", "\u00E9"), ("k:5", "e\u0301"), ("k:6", "\uFF33"), ("k:7", " S")); private static readonly Rec[] NormalKeyRecords = Items_(("\u00E9", "S"), ("e\u0301", "S")); + // Three holders of one value: code-unit order is e+U+0302, f, U+00E9; NFC would order f, U+00E9, e+U+0302 and NFD U+00E9, e+U+0302, f. + private static readonly Rec[] ThreeHolderRecords = Items_(("e\u0302", "S"), ("f", "S"), ("\u00E9", "S")); + // Both records violate both aliases: by code unit U+10400 (D801 DC00) precedes U+FF21, by code point U+FF21 does. + private static readonly Rec[] WideRecords = { new("item:a", Wide, "S", "X"), new("item:b", Wide, "S", "X") }; private static readonly Rec[] KeyPunctuationRecords = Items_(("_", "S"), ("a", "S"), ("Z", "T"), ("b", "T")); private static readonly Rec[] SurrogateGroupRecords = Items_(("\uE000", "S"), ("\uE000b", "S"), ("\U0001F600", "T"), ("\U0001F600b", "T")); @@ -368,7 +379,7 @@ namespace GameCult.Caching.Tests ("surrogate-groups-store.cc", SurrogateGroupRecords), ("holder-case-store.cc", HolderCaseRecords), ("cross-schema-store.cc", CrossSchemaRecords), ("distinct-values-store.cc", DistinctValueRecords), - ("normal-keys-store.cc", NormalKeyRecords), + ("normal-keys-store.cc", NormalKeyRecords), ("normal-three-store.cc", ThreeHolderRecords), ("wide-store.cc", WideRecords), }; private static readonly Refusal SurrogateRefusal = new("k\U0001F600", "k\uE000", Item, "Sku"); @@ -379,6 +390,8 @@ namespace GameCult.Caching.Tests private static readonly Refusal KeyCaseRefusal = new("B", "C", Item, "Sku"); private static readonly Refusal HolderCaseRefusal = new("A", "a", Item, "Sku"); private static readonly Refusal NormalKeyRefusal = new("e\u0301", "\u00E9", Item, "Sku"); + private static readonly Refusal ThreeHolderRefusal = new("e\u0302", "f", Item, "Sku"); + private static readonly Refusal WideRefusal = new("item:a", "item:b", Wide, "\U00010400"); private static readonly Refusal KeyPunctuationRefusal = new("Z", "b", Item, "Sku"); private static readonly Refusal SurrogateGroupRefusal = new("\U0001F600", "\U0001F600b", Item, "Sku"); @@ -430,6 +443,12 @@ namespace GameCult.Caching.Tests new Refusal("a", "m", Pair, "Alt")), new("put-onto-a-held-alias-pair", new[] { Open(), Commit(new Rec("m", Alias, "S", "X")), Commit(new Rec("a", Alias, "S", "X")) }, new Refusal("a", "m", Alias, "Sku")), + // Rows 34-35: three holders of one value; the two least by code unit are named, which no NFC, NFD or culture sort of the keys yields. + new("batch-three-holders-normal-form", new[] { Open(), Commit(ThreeHolderRecords) }, ThreeHolderRefusal), + new("load-three-holders-normal-form", new[] { Open("normal-three-store.cc") }, ThreeHolderRefusal), + // Rows 36-37: two records violating two aliases, U+FF21 and U+10400; aliases compare by code unit, so U+10400 is named. + new("batch-alias-code-unit", new[] { Open(), Commit(WideRecords) }, WideRefusal), + new("load-alias-code-unit", new[] { Open("wide-store.cc") }, WideRefusal), }; private static byte[] RefusalStoreBytes(Rec[] records) @@ -623,7 +642,7 @@ namespace GameCult.Caching.Tests } Assert.That(failed, Is.Empty, "rows that failed"); - Assert.That(walked, Is.EqualTo(33)); + Assert.That(walked, Is.EqualTo(37)); } } } diff --git a/tests/vectors/unique-index/README.md b/tests/vectors/unique-index/README.md index 45e6ef71..55b393f3 100644 --- a/tests/vectors/unique-index/README.md +++ b/tests/vectors/unique-index/README.md @@ -19,14 +19,15 @@ reviewed commit. Other runtimes are held to the same canonical text and the same `" "` and `item:d` holds Sku `U+0085`. Written through a role Sku. - `blank-store.cc`: the same, only `item:c` and `item:d`. - `pair-store.cc`, `surrogate-store.cc`, `types-store.cc`, `repull-before.cc`, `repull-after.cc`, `alias-store.cc`, - `key-case-store.cc`, `key-punctuation-store.cc`, `surrogate-groups-store.cc`, `holder-case-store.cc`, `cross-schema-store.cc`, `distinct-values-store.cc`, `normal-keys-store.cc`: single-file stores written like + `key-case-store.cc`, `key-punctuation-store.cc`, `surrogate-groups-store.cc`, `holder-case-store.cc`, `cross-schema-store.cc`, `distinct-values-store.cc`, `normal-keys-store.cc`, `normal-three-store.cc`, `wide-store.cc`: single-file stores written like `duplicate-store.cc`, through roles, for the rows of `refusals.msgpack` that open or pull them. Schemas `tests.unique_item`, `tests.uq_pair` (Sku and Alt both unique), `tests.uq_alias` (Sku and `alt` both unique), - `tests.uq_alpha` and `tests.uq_beta`, each slots `[0: Sku string, 1: second string]`, schema version the name plus + `tests.uq_wide` (Sku and Alt both unique, aliases U+FF21 and U+10400), `tests.uq_alpha` and `tests.uq_beta`, each slots `[0: Sku string, 1: second string]`, schema version the name plus `.v1`. The schema ids C# writes in the store catalogs and on every record: - `tests.unique_item`: `sha256:93bb890de83069061af696b23f94fd32fb3c2dad3c91ee645e8add56085f1882` - `tests.uq_pair`: `sha256:2538cceafb6dcbce22ef11b932ecb804af5f96bc3aaedff692a52d3640433e60` - `tests.uq_alias`: `sha256:77a77a394ebd8bf71e14b9b6f2cea4978c25d4e705fd12364f660edbf6c63b1f` + - `tests.uq_wide`: `sha256:b79eecd11eed5e456902f095d2748da7f59125c4c8972b0da7dddc81d07fc03f` - `tests.uq_alpha`: `sha256:53aeaf2ea9b25e85ade6b15fc04c7653e7b206c87e258f1aa74dd32a840a1389` - `tests.uq_beta`: `sha256:45ed62a64c91f6d815d3ec975c4a4a1b2ce07b6e7262a32d828673cf10899586` - `outcomes.msgpack`: rows `[file, unique alias or nil, open|refused, k1 or nil, k2 or nil]`. Opening `file` with that @@ -51,7 +52,7 @@ last succeeds and the last refuses with exactly the RULE text built from those f byte-identical. Schemas are those of the store files above; `second` is `Category` (plain) except in `tests.uq_pair` and `tests.uq_alias`, where it is `Alt` (unique, alias `Alt` and `alt` respectively). -The 13 rows of the spec are a floor; rows 14-33 extend it with orderings that a code-point, case-insensitive or +The 13 rows of the spec are a floor; rows 14-37 extend it with orderings that a code-point, case-insensitive or culture compare would flip. Rows, in file order: 1 batch-later-value-staged-first, 2 batch-surrogate-keys, 3 batch-interleaved-types, 4 batch-two-aliases, 5 held-then-landing, 6 held-in-the-least-group, 7 swap-off-a-held-value, 8 load-two-aliases, 9 load-surrogate-keys, 10 load-interleaved-types, 11 repull-held-pair, 12 load-repull-after, 13 @@ -63,17 +64,20 @@ load-surrogate-groups, 23 batch-holders-by-case (`a` and `A` holding one value: load-value-in-two-schemas, 28 batch-distinct-values (`S`, `s`, `S `, ` S`, U+00E9, `e`+U+0301, U+FF33 are seven values: success), 29 load-distinct-values, 30 batch-keys-by-normal-form (keys U+00E9 and `e`+U+0301 holding one value are two holders, `e`+U+0301 first), 31 load-keys-by-normal-form, 32 put-onto-a-held-pair (held `m` and landing `a` share Sku and Alt in `tests.uq_pair`, one record per -commit: `Alt` is named before `Sku`), 33 put-onto-a-held-alias-pair (the same in `tests.uq_alias`: `Sku` before `alt`). -Rows 19-22, 24, 27, 29 and 31 open a store holding the records of rows 18, 14, 15, 17, 23, 26, 28 and 30, so a runtime with no batch +commit: `Alt` is named before `Sku`), 33 put-onto-a-held-alias-pair (the same in `tests.uq_alias`: `Sku` before `alt`), 34 batch-three-holders-normal-form +(keys `e`+U+0302, `f` and U+00E9 holding one value: the two least by code unit, `e`+U+0302 and `f`, are named; an NFC, NFD or culture sort of the keys names another pair), 35 +load-three-holders-normal-form, 36 batch-alias-code-unit (two records each holding the same value under both aliases of `tests.uq_wide`, U+FF21 and U+10400: by UTF-16 code +unit U+10400 (D801 DC00) precedes U+FF21, by code point it follows it, so the refusal names alias U+10400), 37 load-alias-code-unit. +Rows 19-22, 24, 27, 29, 31, 35 and 37 open a store holding the records of rows 18, 14, 15, 17, 23, 26, 28, 30, 34 and 36, so a runtime with no batch commit still walks alias and cross-group key order. A runtime performs every row its API can perform and asserts the count it walks. These are the counts a runtime must assert, not a statement of which runtimes walk them today: -- C# and Rust: all 33. -- Python: 31, every row but 3 and 26 (`put_envelopes` takes one type, so no batch of two types). -- TypeScript: 18, the rows with no multi-record commit: 5, 8, 9, 10, 11, 12, 13, 19, 20, 21, 22, 24, 25, 27, 29, 31, 32 and 33 - (no batch commit, so no row 1-4, 6, 7, 14-18, 23, 26, 28 or 30; `variants:idea:unique-index-runtimes.ts-batch-commit`). +- C# and Rust: all 37. +- Python: 35, every row but 3 and 26 (`put_envelopes` takes one type, so no batch of two types). +- TypeScript: 20, the rows with no multi-record commit: 5, 8, 9, 10, 11, 12, 13, 19, 20, 21, 22, 24, 25, 27, 29, 31, 32, 33, 35 and 37 + (no batch commit, so no row 1-4, 6, 7, 14-18, 23, 26, 28, 30, 34 or 36; `variants:idea:unique-index-runtimes.ts-batch-commit`). Not pinned: the schema-name tiebreak. Record keys are unique across a whole cache, so two types never hold the same key and two groups can never tie on alias and key pair; the final `schema` comparison cannot decide a refusal. diff --git a/tests/vectors/unique-index/normal-three-store.cc b/tests/vectors/unique-index/normal-three-store.cc new file mode 100644 index 0000000000000000000000000000000000000000..580ae03892da769d0359e303139744b53d7a956d GIT binary patch literal 1063 zcmc(dy-ve06oo0u4fgl!ykYit$h$MyBK~#u^H{eHtk-`E41BeGe zg^?G4r{U5<1u7eVkr&?_pYNQ+hc~j$trT(%Tw~i6xLe--@zb1H6EtKo_tP}uL_y>e z#tHL;W}Kze*F4b5r#kf|RYLiadjKp5AreZ3j6|XKqKIBwFxI$rnQhjvnpr4rM8X&_V2#}oW{yx4IJmMO%% zD8PxG5uDR_6D&bxvUd5E1|y-YfFebW6ECA@-nLFm8csQ|P1b8v*v`f)Bk34y<|ekv z$`+0@vj7?@C?&qtA~%3$qU;PeR#Yvp$%mX}o#%M4RIojhX9A(l0FjwNk-p7t<2{Kt zOfoAhwAxkYm*czz{FG*8cYpZjzI#&+PR7o$@ztkq+3Vf)UZ*GH7w=kU1q pV@aMSDc-qh;^A5(BXP&Mi1=pnt~-CCQN9Uyu};r4K&-Kj$aG1LKp66s0(nL F1^^!+k0k&A delta 12 TcmcbsI8ToG4uj%GW@&x^9N+_) diff --git a/tests/vectors/unique-index/wide-store.cc b/tests/vectors/unique-index/wide-store.cc new file mode 100644 index 0000000000000000000000000000000000000000..2dcc1e4a8b2183ff69ebd251d0726b1af138d881 GIT binary patch literal 935 zcmc(d%`OB%6opxM5WO`zRW<#;v$C_$A`%g)pIfG*yN9kCOhn=l7!tOV5j%-kTgb#q z@DeB%S#nIO2Ml#&ruB-E1B^^FB%joaGY+|Lx;PE?pQ&XLq7 zpwTCYJrQDz=S7&!B^7mAry=t-AtGQxM_S@5RH(f`Xk-flpz-sxICKm)QSc>RfRi{A zQBLh$T!qSH&GM^`D0rlRLZI#>>*$p?t&@t(ZsB%j zmO(=Wr9@Tp+yI*KvOO*=ueQLZpZLmFp5w(*!NEeFaSU}{?z=;;Q3FGpquK`v9g{5N z_OD4^Pfm`%p==zTjz8B=e~ZrLR1YV5Gcm}1|DoT*AzV)Pu*dJ()c~t=ex2d$6aH%~ iPqU1$V4hH1D}*G&<&x1eH>@6KI^DFp_8u+%A^Hv6E;v#E literal 0 HcmV?d00001 From f2bb0dd1dd72faca1c9c018b026dbd0195bf5ed4 Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 13:56:35 +0000 Subject: [PATCH 28/93] cultcache-ts: tests pin the single-put judge's alias and key-pair order (#108) --- .../cultcache-ts/test/unique-index.test.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/packages/cultcache-ts/test/unique-index.test.ts b/packages/cultcache-ts/test/unique-index.test.ts index 955c439a..d36c2cf2 100644 --- a/packages/cultcache-ts/test/unique-index.test.ts +++ b/packages/cultcache-ts/test/unique-index.test.ts @@ -550,3 +550,34 @@ test("registerIndex of a second unique alias judges it, naming the two smallest assert.equal(cache.getKeyByIndex(document, "a", "1"), "z", "the alias already installed is intact"); assert.equal(cache.getKeyByIndex(document, "b", "s"), undefined); }); + +// ---- the single-put judge's two orders, which no pair-order vector row reaches from TS (a vector row's second record +// arrives in a batch that TS cannot perform): aliases by UTF-16 code unit, then the key pair by UTF-16 code unit ---- + +test("a put colliding on two aliases names the one least by UTF-16 code unit, not by code point", async () => { + // U+10400 is the surrogate pair D801 DC00, so it sorts before U+FF21 by code unit and after it by code point. + const document = pair({ indexes: { "A": "a", "\u{10400}": "b" } }); + const cache = memory(document); + await cache.put(document, "h1", { a: "s1", b: "t1" }); + await cache.put(document, "h2", { a: "s2", b: "t2" }); + await assert.rejects(cache.put(document, "p", { a: "s1", b: "t2" }), { message: refusal("h2", "p", "tests.pair", "\u{10400}") }); + assert.equal(cache.get(document, "p"), undefined); +}); + +test("a put names its key pair by UTF-16 code unit, not by normalised form", async () => { + // "e" U+0302 is the NFD form of U+00EA: equal when normalised, and less by code unit. + const document = item("item"); + const cache = memory(document); + await cache.put(document, "ê", { sku: "v" }); + await assert.rejects(cache.put(document, "eÌ‚", { sku: "v" }), { message: refusal("eÌ‚", "ê", "tests.item", "sku") }); + assert.equal(cache.get(document, "eÌ‚"), undefined); +}); + +test("a put colliding on two aliases orders them by UTF-16 code unit, not by normalised form", async () => { + // U+00EA normalises to e U+0302, which is less than e U+0303 when normalised and greater by code unit. + const document = pair({ indexes: { "ê": "a", "ẽ": "b" } }); + const cache = memory(document); + await cache.put(document, "h1", { a: "s1", b: "t1" }); + await cache.put(document, "h2", { a: "s2", b: "t2" }); + await assert.rejects(cache.put(document, "p", { a: "s1", b: "t2" }), { message: refusal("h2", "p", "tests.pair", "ẽ") }); +}); From b88c0a8ee2074b7dd62d71c5d9e761e9544e19c5 Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 14:24:23 +0000 Subject: [PATCH 29/93] CI: one portability run per change; quic-native only for native paths (#109) --- .github/workflows/cultmesh-portability.yml | 15 +++++++++++---- .github/workflows/quic-native.yml | 8 ++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/.github/workflows/cultmesh-portability.yml b/.github/workflows/cultmesh-portability.yml index 1a838e94..959171ce 100644 --- a/.github/workflows/cultmesh-portability.yml +++ b/.github/workflows/cultmesh-portability.yml @@ -1,11 +1,18 @@ name: CultMesh portability and performance +# The forge runs one job at a time and this workflow holds the slot 14 to 30 minutes, so it runs once per change: +# on the pull request (never again on the push that merges it; a break that merges from a stale base waits for the +# schedule), skipped for design-note-only changes, and on a +# daily schedule so main is still exercised when nobody opens a pull request. on: - push: - branches: - - main - - "codex/**" pull_request: + paths-ignore: + # Not `**/*.md` or `docs/**`: docs/start/README.md is read by the first-call tests of every runtime. + - "docs/*.md" + - "research/**" + - ".voidbot/**" + schedule: + - cron: "17 3 * * *" workflow_dispatch: concurrency: diff --git a/.github/workflows/quic-native.yml b/.github/workflows/quic-native.yml index 6a4c9cdf..8f1fed18 100644 --- a/.github/workflows/quic-native.yml +++ b/.github/workflows/quic-native.yml @@ -9,13 +9,21 @@ name: CultMesh QUIC native # from the pinned debian:13 container below, never from a local or Yggdrasil # build. +# Only a change to the committed binaries, their manifest or this workflow can change what verify-committed checks, +# so a branch push or pull request runs it for those paths alone; a tag always runs it. on: push: branches: - main + paths: + - "packages/cultmesh-ts/native/**" + - ".github/workflows/quic-native.yml" tags: - "cultmesh-quic-native-v*" pull_request: + paths: + - "packages/cultmesh-ts/native/**" + - ".github/workflows/quic-native.yml" workflow_dispatch: concurrency: From 4246a372841b6555383470a856ae07f6f1544cb0 Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 15:07:48 +0000 Subject: [PATCH 30/93] Single-file readers refuse duplicate keys in all four runtimes; c0 bad-canonical row (#110) --- .../cultcache-py/src/cultcache_py/stores.py | 39 ++- packages/cultcache-py/tests/test_cultcache.py | 93 +++++++ packages/cultcache-rs/src/lib.rs | 234 ++++++++++++++++-- .../single-file-messagepack-backing-store.ts | 55 +++- packages/cultcache-ts/test/cult-cache.test.ts | 41 +++ packages/cultcache-ts/test/store-file.test.ts | 87 ++++++- .../CultDocumentMessagePackSerialization.cs | 14 +- .../StoreReadabilityTests.cs | 12 + .../readability/distinct-keys-case-v3.bin | 1 + .../readability/distinct-keys-nfd-v3.bin | 1 + .../dup-key-different-payload-v3.bin | 1 + .../readability/dup-key-other-schema-v3.bin | 1 + .../readability/dup-key-same-schema-v3.bin | 1 + .../readability/generate.py | 20 ++ .../readability/legacy-dup-key.bin | 1 + .../readability/manifest.txt | 8 + 16 files changed, 569 insertions(+), 40 deletions(-) create mode 100644 tests/vectors/document-variants-c2a/readability/distinct-keys-case-v3.bin create mode 100644 tests/vectors/document-variants-c2a/readability/distinct-keys-nfd-v3.bin create mode 100644 tests/vectors/document-variants-c2a/readability/dup-key-different-payload-v3.bin create mode 100644 tests/vectors/document-variants-c2a/readability/dup-key-other-schema-v3.bin create mode 100644 tests/vectors/document-variants-c2a/readability/dup-key-same-schema-v3.bin create mode 100644 tests/vectors/document-variants-c2a/readability/legacy-dup-key.bin diff --git a/packages/cultcache-py/src/cultcache_py/stores.py b/packages/cultcache-py/src/cultcache_py/stores.py index 77d671ad..db24b3bd 100644 --- a/packages/cultcache-py/src/cultcache_py/stores.py +++ b/packages/cultcache-py/src/cultcache_py/stores.py @@ -245,11 +245,34 @@ def _derive_catalog(records: list[tuple[CultCacheEnvelope, bool]]) -> list[CultC return [entry for entry, _ in chosen.values()] +def _retyped(held: CultCacheEnvelope, written: CultCacheEnvelope) -> SchemaConflictError: + """The refusal of a write that would put a second type under a key the store already holds (C# CacheBackingStore.Retyped).""" + return SchemaConflictError( + _schema_id_for(held), + [held.type], + held.key, + message=( + f"Record '{held.key}' is a {held.type} (schema id '{_schema_id_for(held)}'); writing a {written.type} there would " + "retype the key. Remove the record first, in the same batch or an earlier commit, then add the other type's." + ), + ) + + def _encode_snapshot( supplied: list[CultCacheEnvelope], arrived: list[CultCacheEnvelope], format_version: str ) -> list[Any]: - replaced = {(envelope.type, envelope.key) for envelope in supplied} - kept = [envelope for envelope in arrived if (envelope.type, envelope.key) not in replaced] + # A key names one record whatever its type, so a supplied record replaces the arrived one by key alone, and only of the + # same type: retyping a key is a removal and an add, so a record of another type under the key refuses the write. + held_by_key = {envelope.key: envelope for envelope in arrived} + landing: dict[str, CultCacheEnvelope] = {} + for envelope in supplied: + for held in (landing.get(envelope.key), held_by_key.get(envelope.key)): + if held is not None and held.type != envelope.type: + raise _retyped(held, envelope) + landing[envelope.key] = envelope + supplied = list(landing.values()) + replaced = set(landing) + kept = [envelope for envelope in arrived if envelope.key not in replaced] envelopes = [*kept, *supplied] catalog = [ _encode_catalog_entry(entry) @@ -400,6 +423,7 @@ def _decode_snapshot(decoded: Any) -> tuple[str, list[CultCacheEnvelope]] | None catalog_by_schema_id.setdefault(compatible_schema_id, entry) envelopes: list[CultCacheEnvelope] = [] + seen: set[str] = set() for raw_record in decoded[2]: if not isinstance(raw_record, list) or len(raw_record) < 4: raise ValueError("CultCache persisted records must be MessagePack arrays") @@ -412,6 +436,9 @@ def _decode_snapshot(decoded: Any) -> tuple[str, list[CultCacheEnvelope]] | None key, schema_id, stored_at, payload = raw_record if not isinstance(key, str) or not key: raise ValueError("CultCache persisted records must declare a key") + if key in seen: + raise ValueError(f"CultCache record {key!r} appears more than once") + seen.add(key) if not isinstance(schema_id, str) or not schema_id: raise ValueError("CultCache persisted records must declare a schema id") if not isinstance(stored_at, str) or not stored_at: @@ -437,9 +464,13 @@ def _decode_legacy_envelopes(decoded: Any) -> list[CultCacheEnvelope]: if not isinstance(decoded, list): raise ValueError("CultCache MessagePack store is not a recognized snapshot") envelopes: list[CultCacheEnvelope] = [] + seen: set[Any] = set() for item in decoded: if not isinstance(item, dict): raise ValueError("CultCache legacy envelopes must be MessagePack maps") + if item.get("key") in seen: + raise ValueError(f"CultCache record {item['key']!r} appears more than once") + seen.add(item.get("key")) try: payload = item["payload"] envelopes.append( @@ -484,12 +515,14 @@ def _decode_catalog_entry(value: Any) -> CultCacheSchemaCatalogEntry: raise ValueError(f'CultCache schema catalog entry "{schema_id}" has invalid compatible schema ids') if not isinstance(members, list): raise ValueError(f'CultCache schema catalog entry "{schema_id}" has invalid members') + if not isinstance(canonical_schema_json, str): + raise ValueError(f'CultCache schema catalog entry "{schema_id}" has an invalid canonical schema') return CultCacheSchemaCatalogEntry( schema_id=schema_id, schema_name=schema_name, schema_version=schema_version if isinstance(schema_version, str) and schema_version else f"{schema_name}.v1", content_hash=content_hash if isinstance(content_hash, str) and content_hash else schema_id, - canonical_schema_json=canonical_schema_json if isinstance(canonical_schema_json, str) else "", + canonical_schema_json=canonical_schema_json, compatible_schema_ids=tuple(compatible_schema_ids), members=tuple(_decode_catalog_member(member) for member in members), ) diff --git a/packages/cultcache-py/tests/test_cultcache.py b/packages/cultcache-py/tests/test_cultcache.py index 92892b51..2be0a84b 100644 --- a/packages/cultcache-py/tests/test_cultcache.py +++ b/packages/cultcache-py/tests/test_cultcache.py @@ -1604,6 +1604,99 @@ class CultCacheTests(unittest.TestCase): self.assertIsNotNone(refused.exception.__cause__, f"{vector} {operation}") self.assertEqual(store_path.read_bytes(), content, f"{vector} {operation} rewrote a file it cannot read") + @staticmethod + def _rec(type: str, key: str, second: int = 0) -> CultCacheEnvelope: + return CultCacheEnvelope(key=key, type=type, payload=b"\x90", stored_at=f"2026-09-30T00:00:0{second}Z") + + def test_a_supplied_record_replaces_an_arrived_one_of_the_same_type_by_key(self) -> None: + import msgpack # type: ignore + + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "store.msgpack" + store = SingleFileMessagePackBackingStore(path) + store.push(self._rec("first", "k")) + store.push(self._rec("first", "k", 1)) + store.push_all([self._rec("first", "k", 2), self._rec("first", "k", 3)]) + records = msgpack.unpackb(path.read_bytes(), raw=False)[2] + self.assertEqual([record[0] for record in records], ["k"]) + self.assertEqual([(e.type, e.stored_at) for e in store.pull_all()], [("first", "2026-09-30T00:00:03Z")]) + + def test_a_write_that_would_retype_a_key_is_refused_and_leaves_the_file(self) -> None: + from cultcache_py import SchemaConflictError + + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "store.msgpack" + store = SingleFileMessagePackBackingStore(path) + store.push(self._rec("first", "k")) + content = path.read_bytes() + for run in ( + lambda: store.push(self._rec("second", "k", 1)), + lambda: store.push_all([self._rec("other", "j"), self._rec("second", "k", 1)]), + ): + with self.assertRaises(SchemaConflictError) as refused: + run() + self.assertEqual(refused.exception.record_key, "k") + self.assertEqual(refused.exception.schema_names, ["first"]) + self.assertIn("would retype the key", str(refused.exception)) + self.assertEqual(path.read_bytes(), content) + # Retyping is a removal and an add. + store.delete("first", "k") + store.push(self._rec("second", "k", 1)) + self.assertEqual([e.type for e in SingleFileMessagePackBackingStore(path).pull_all()], ["second"]) + + def test_a_batch_naming_one_key_under_two_types_is_refused_whole(self) -> None: + from cultcache_py import SchemaConflictError + + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "store.msgpack" + store = SingleFileMessagePackBackingStore(path) + with self.assertRaises(SchemaConflictError): + store.push_all([self._rec("first", "k"), self._rec("second", "k", 1)]) + self.assertFalse(path.exists()) + + def test_a_cache_put_of_a_second_type_under_a_key_is_refused_and_persists_nothing_new(self) -> None: + from cultcache_py import SchemaConflictError + + other = define_document_type("other", encode=lambda item: asdict(item), decode=lambda raw: Item(**raw)) + document = item_doc() + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "store.msgpack" + + def build() -> CultCache: + cache = ( + CultCache.builder() + .register_document_type(document) + .register_document_type(other) + .add_generic_store(SingleFileMessagePackBackingStore(path)) + .build() + ) + cache.pull_all_backing_stores() + return cache + + cache = build() + cache.put(document, "shared", Item(name="A", category="c", value=1)) + content = path.read_bytes() + with self.assertRaises(SchemaConflictError): + cache.put(other, "shared", Item(name="B", category="c", value=2)) + self.assertEqual(path.read_bytes(), content) + self.assertIsNone(cache.get(other, "shared")) + reloaded = build() + self.assertEqual(reloaded.get_required(document, "shared").value, 1) + self.assertIsNone(reloaded.get(other, "shared")) + cache.delete(document, "shared") + cache.put(other, "shared", Item(name="B", category="c", value=2)) + self.assertEqual(build().get_required(other, "shared").value, 2) + + def test_a_catalog_entry_with_a_non_string_canonical_schema_is_unreadable(self) -> None: + # Direct probe of the c0 vector; its manifest row is added by the TS cut. + vector = self._C2A_VECTORS.parent / "document-variants-c0" / "current-catalog-bad-canonical.msgpack" + content = vector.read_bytes() + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "store.msgpack" + path.write_bytes(content) + with self.assertRaises(StoreUnreadableError): + SingleFileMessagePackBackingStore(path).pull_all() + self.assertEqual(path.read_bytes(), content) if __name__ == "__main__": diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index e5e88b9c..8984cd29 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -143,6 +143,8 @@ use serde::de::DeserializeOwned; use std::any::Any; use std::collections::BTreeMap; use std::collections::BTreeSet; +use std::collections::HashMap; +use std::collections::HashSet; use std::fs; use std::fs::File; use std::fs::OpenOptions; @@ -929,7 +931,8 @@ fn store_header(bytes: &[u8]) -> StoreHeader { } /// A write the catalog cannot describe: records of different types under one schema id. This writer derives one entry per carried -/// schema id from its records, so that is its only conflict. Nothing is written. It names the id, the two names and a record key. +/// schema id from its records, so that is its only conflict besides a single-file write that would put a second type under a +/// key the store already holds (the key is the record's identity there). Nothing is written. It names the id, the two names and a record key. #[derive(Debug)] pub struct SchemaConflictError { pub schema_id: String, @@ -1476,6 +1479,7 @@ impl SingleFileMessagePackBackingStore { } fn write_all_unlocked(&self, entries: &[CultCacheEnvelope]) -> Result<()> { + require_one_record_per_key(entries)?; let failed = |kind| CultCacheStoreWriteFailed { path: self.path.clone(), kind, @@ -1745,6 +1749,7 @@ impl CacheBackingStore for SingleFileMessagePackBackingStore { fn push(&mut self, entry: &CultCacheEnvelope) -> Result<()> { self.with_exclusive_lock(|| { let mut entries = self.read_all_unlocked()?; + // A write replaces the record of its own type under its key; a second type under the key reaches the commit guard. entries.retain(|candidate| entry_id(candidate) != entry_id(entry)); entries.push(entry.clone()); entries.sort_by_key(entry_id); @@ -1768,7 +1773,13 @@ impl CacheBackingStore for SingleFileMessagePackBackingStore { return Ok(()); } - let mut entries = entries.to_vec(); + // Of same-type entries sharing a key the last one given is the record; a second type under a key reaches the + // commit guard. + let mut by_id = BTreeMap::<(String, String), &CultCacheEnvelope>::new(); + for entry in entries { + by_id.insert(entry_id(entry), entry); + } + let mut entries: Vec = by_id.into_values().cloned().collect(); entries.sort_by_key(entry_id); self.write_all_unlocked(&entries) }) @@ -3709,7 +3720,41 @@ impl DiskStore { /// The legacy envelope array: an array of envelope maps. Its acceptance is the one verdict on an array whose first slot /// is not a store header, for reading it and for replacing it. fn decode_legacy_envelopes(bytes: &[u8]) -> Result> { - decode_grown(bytes).context("failed to decode legacy CultCache envelope array") + let envelopes: Vec = + decode_grown(bytes).context("failed to decode legacy CultCache envelope array")?; + require_unique_keys(&envelopes)?; + Ok(envelopes) +} + +/// A record's identity in a store is its key string alone, compared ordinal: a store holding a key twice is unreadable, +/// whatever the format of the file that holds it. +fn require_unique_keys(envelopes: &[CultCacheEnvelope]) -> Result<()> { + let mut keys = HashSet::<&str>::new(); + for envelope in envelopes { + if !keys.insert(envelope.key.as_str()) { + bail!("CultCache record {:?} appears more than once", envelope.key); + } + } + Ok(()) +} + +/// The commit guard every single-file write passes: a store holds one record per key, so a snapshot about to be written +/// may not hold a key twice. Two types under a key are a retype, refused with the key and both types (retyping is +/// remove-then-add); the same type twice is a malformed snapshot. Nothing is written. +fn require_one_record_per_key(entries: &[CultCacheEnvelope]) -> Result<()> { + let mut held = HashMap::<&str, &CultCacheEnvelope>::new(); + for entry in entries { + let Some(first) = held.insert(entry.key.as_str(), entry) else { continue }; + if first.r#type == entry.r#type { + bail!("CultCache snapshot holds record {:?} of type {:?} more than once", entry.key, entry.r#type); + } + return Err(anyhow::Error::new(SchemaConflictError { + schema_id: entry.schema_id.clone().unwrap_or_else(|| entry.r#type.clone()), + schema_names: vec![first.r#type.clone(), entry.r#type.clone()], + record_key: entry.key.clone(), + })); + } + Ok(()) } /// Decodes a store whose header the caller has already read as a readable one: its envelopes and its catalog. @@ -3748,6 +3793,7 @@ fn decode_store_snapshot(bytes: &[u8]) -> Result<(Vec, Vec>>()?; + require_unique_keys(&envelopes)?; Ok((envelopes, snapshot.1)) } @@ -4219,32 +4265,158 @@ mod tests { Ok(()) } + fn settings_one() -> Settings { + Settings { theme: "green".to_string(), retries: 1 } + } + + fn note_one() -> Note { + Note { title: "same key".to_string(), body: "different type".to_string() } + } + + fn retype_refusal(error: &anyhow::Error) -> &SchemaConflictError { + error.downcast_ref::().unwrap_or_else(|| panic!("{error:#}")) + } + + fn reloaded(path: &Path) -> Result> { + SingleFileMessagePackBackingStore::new(path).pull_all() + } + #[test] - fn entry_identity_is_polymorphic_by_type_and_key() -> Result<()> { + fn a_second_type_under_a_stored_key_is_refused_and_the_store_and_cache_stay() -> Result<()> { let temp = tempfile::tempdir()?; let store_path = temp.path().join("cache.cc"); let mut cache = CultCache::new(); cache.register_registry(TestEntries)?; cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; - cache.put( - "shared", - &Settings { - theme: "green".to_string(), - retries: 1, - }, - )?; - cache.put( - "shared", - &Note { - title: "same key".to_string(), - body: "different type".to_string(), - }, - )?; - - assert_eq!(cache.snapshot().len(), 2); - assert_eq!(cache.get_required::("shared")?.title, "same key"); + cache.put("shared", &settings_one())?; + let before = std::fs::read(&store_path)?; + let error = cache.put("shared", ¬e_one()).unwrap_err(); + assert_eq!(retype_refusal(&error).record_key, "shared"); + assert_eq!(std::fs::read(&store_path)?, before, "the refused write changed the file"); + assert_eq!(cache.snapshot().len(), 1); assert_eq!(cache.get_required::("shared")?.theme, "green"); + assert!(cache.get::("shared")?.is_none()); + + // Retyping is remove-then-add, and what was written reads back. + assert!(cache.delete::("shared")?); + cache.put("shared", ¬e_one())?; + let held = reloaded(&store_path)?; + assert_eq!(held.len(), 1); + assert_eq!(held[0].r#type, Note::TYPE); + + // The same type under the key replaces it. + cache.put("shared", &Note { title: "again".to_string(), body: String::new() })?; + let held = reloaded(&store_path)?; + assert_eq!(held.len(), 1); + assert_eq!(cache.get_required::("shared")?.title, "again"); + Ok(()) + } + + #[test] + fn a_batch_holding_a_second_type_under_a_key_is_refused_whole() -> Result<()> { + let temp = tempfile::tempdir()?; + let store_path = temp.path().join("batch.cc"); + let mut cache = CultCache::new(); + cache.register_registry(TestEntries)?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; + cache.put("kept", &settings_one())?; + let before = std::fs::read(&store_path)?; + + let (settings, _) = cache.prepare_entry("shared", &settings_one())?; + let (note, _) = cache.prepare_entry("shared", ¬e_one())?; + let error = cache.put_prepared_batch(vec![settings.clone(), note.clone()]).unwrap_err(); + assert_eq!(retype_refusal(&error).record_key, "shared"); + assert_eq!(std::fs::read(&store_path)?, before); + assert_eq!(cache.snapshot().len(), 1); + + // The store's own batch write refuses it too, in either order. + let mut store = SingleFileMessagePackBackingStore::new(&store_path); + for batch in [[settings.clone(), note.clone()], [note, settings]] { + let error = store.push_all(&batch, PushAllOptions::default()).unwrap_err(); + assert_eq!(retype_refusal(&error).record_key, "shared"); + assert_eq!(std::fs::read(&store_path)?, before); + } + Ok(()) + } + + #[test] + fn the_stores_own_push_refuses_a_second_type_under_a_key() -> Result<()> { + let temp = tempfile::tempdir()?; + let store_path = temp.path().join("push.cc"); + let mut cache = CultCache::new(); + cache.register_registry(TestEntries)?; + let (settings, _) = cache.prepare_entry("shared", &settings_one())?; + let (note, _) = cache.prepare_entry("shared", ¬e_one())?; + let mut store = SingleFileMessagePackBackingStore::new(&store_path); + store.push(&settings)?; + let before = std::fs::read(&store_path)?; + let error = store.push(¬e).unwrap_err(); + let mut names = retype_refusal(&error).schema_names.clone(); + names.sort(); + assert_eq!(names, vec![Note::TYPE.to_string(), Settings::TYPE.to_string()]); + assert_eq!(std::fs::read(&store_path)?, before); + Ok(()) + } + + #[test] + fn no_single_file_writer_commits_a_second_type_under_a_held_key() -> Result<()> { + let mut cache = CultCache::new(); + cache.register_registry(TestEntries)?; + let (held, _) = cache.prepare_entry("k", &settings_one())?; + let (other_key, _) = cache.prepare_entry("c", &settings_one())?; + let (note, _) = cache.prepare_entry("k", ¬e_one())?; + let absent_note = CultCacheExpectedEnvelope { key: "k".to_string(), r#type: Note::TYPE.to_string(), current: None }; + type Writer = fn(&mut SingleFileMessagePackBackingStore, &[CultCacheEnvelope; 3], &CultCacheExpectedEnvelope) -> Result; + let writers: Vec<(&str, Writer)> = vec![ + ("push", |s, e, _| s.push(&e[2]).map(|()| true)), + ("push_all", |s, e, _| s.push_all(&[e[0].clone(), e[2].clone()], PushAllOptions::default()).map(|()| true)), + ("insert_entry_if_absent", |s, e, _| s.insert_entry_if_absent(e[2].clone())), + ("compare_exchange", |s, e, x| s.compare_exchange(std::slice::from_ref(x), &[e[2].clone()])), + ("compare_and_swap_batch", |s, e, _| s.compare_and_swap_batch(&[], vec![e[2].clone()])), + ("append_if_snapshot_unchanged", |s, e, _| s.append_if_snapshot_unchanged(&[e[0].clone(), e[1].clone()], vec![e[2].clone()])), + ("replace_and_append_if_snapshot_unchanged", |s, e, _| { + s.replace_and_append_if_snapshot_unchanged(&[e[0].clone(), e[1].clone()], vec![e[2].clone()]) + }), + ("replace_and_delete_if_snapshot_unchanged", |s, e, _| { + s.replace_and_delete_if_snapshot_unchanged(&[e[0].clone(), e[1].clone()], vec![e[2].clone()], std::slice::from_ref(&e[1])) + }), + ("compare_exchange_snapshot", |s, e, _| s.compare_exchange_snapshot(&[e[1].clone(), e[0].clone()], &[e[0].clone(), e[2].clone()])), + ("try_compare_exchange_snapshot", |s, e, _| { + s.try_compare_exchange_snapshot(&[e[1].clone(), e[0].clone()], &[e[0].clone(), e[2].clone()]).map(|_| true) + }), + ]; + let envelopes = [held.clone(), other_key.clone(), note]; + for (name, write) in writers { + let temp = tempfile::tempdir()?; + let path = temp.path().join("store.cc"); + let mut store = SingleFileMessagePackBackingStore::new(&path); + store.push_all(&[held.clone(), other_key.clone()], PushAllOptions::default())?; + let before = std::fs::read(&path)?; + let error = write(&mut store, &envelopes, &absent_note).expect_err(name); + assert_eq!(retype_refusal(&error).record_key, "k", "{name}"); + assert_eq!(std::fs::read(&path)?, before, "{name} changed the file"); + assert!(store.pull_all().is_ok(), "{name}"); + } + Ok(()) + } + + #[test] + fn a_legacy_array_with_a_key_under_two_types_is_not_decodable() -> Result<()> { + let temp = tempfile::tempdir()?; + let store_path = temp.path().join("legacy.cc"); + let record = |kind: &str| { + CultCacheEnvelope { + key: "a".to_string(), + r#type: kind.to_string(), + payload: rmp_serde::to_vec(&Vec::::new()).unwrap(), + stored_at: "2026-01-01T00:00:00Z".to_string(), + schema_id: None, + } + }; + std::fs::write(&store_path, rmp_serde::to_vec_named(&vec![record("old.one"), record("old.two")])?)?; + let error = SingleFileMessagePackBackingStore::new(&store_path).pull_all().unwrap_err(); + assert_eq!(unreadable_kind(&error), Some(CultCacheStoreUnreadableKind::Undecodable), "{error:#}"); Ok(()) } @@ -5908,6 +6080,26 @@ mod tests { } + // The writer keeps one record per key: another type under a held key is refused and the file stays; the same type replaces. + #[test] + fn a_write_of_another_type_under_an_existing_key_is_refused_and_the_same_type_replaces() -> Result<()> { + let temp = tempfile::tempdir()?; + let path = temp.path().join("store.msgpack"); + let mut store = SingleFileMessagePackBackingStore::new(&path); + let mut other = snapshot_envelope("k", b"two"); + other.r#type = "snapshot.other".to_string(); + other.schema_id = Some("snapshot.other".to_string()); + store.push(&snapshot_envelope("k", b"one"))?; + let before = std::fs::read(&path)?; + assert!(store.push(&other).unwrap_err().downcast_ref::().is_some()); + assert!(store.push_all(&[snapshot_envelope("k", b"one"), other.clone()], PushAllOptions::default()).is_err()); + assert_eq!(std::fs::read(&path)?, before); + store.push(&snapshot_envelope("k", b"three"))?; + let read = store.pull_all()?; + assert_eq!((read.len(), read[0].payload.as_slice()), (1, b"three".as_slice())); + Ok(()) + } + // A file is replaced by a rewrite exactly when this runtime's own reader opens it: one verdict per file, asked by open, // push_all and push alike. The bytes and every runtime's verdict are tests/vectors/document-variants-c2a/readability. // A refusal is a CultCacheStoreUnreadable, and a replaced file carries the header its old content decides. diff --git a/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts b/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts index 6428ef48..4fdb2fb6 100644 --- a/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts +++ b/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts @@ -60,9 +60,8 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore { const disk = this.#readDisk(); this.#format = disk.format; const existing = disk.envelopes; - const filtered = existing.filter( - (candidate) => !(candidate.type === entry.type && candidate.key === entry.key), - ); + requireOneTypePerKey(this.filePath, [entry], existing); + const filtered = existing.filter((candidate) => candidate.key !== entry.key); await this.#writeAll([entry], filtered); }); } @@ -103,7 +102,9 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore { this.#format = STORE_FORMAT_VERSION; } - await this.#writeAll(entries, []); + requireOneTypePerKey(this.filePath, entries, []); + // A key is held once: of same-type entries sharing a key in one batch, the last is kept. + await this.#writeAll([...new Map(entries.map((entry) => [entry.key, entry])).values()], []); }); } @@ -137,6 +138,28 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore { } } +// A store holds a key once, and the reader refuses a file that holds it twice, so a write that would put a second type under a +// key (against a record on disk, or within its own batch) is refused before anything is written. Retyping a record is a delete +// of the old type followed by a put of the new one. +function requireOneTypePerKey(path: string, supplied: CultCacheEnvelope[], held: CultCacheEnvelope[]): void { + const typeByKey = new Map(); + for (const entry of held) { + typeByKey.set(entry.key, entry); + } + for (const entry of supplied) { + const prior = typeByKey.get(entry.key); + if (prior !== undefined && prior.type !== entry.type) { + throw new SchemaConflictError( + schemaIdForEnvelope(entry), + [prior.type, entry.type], + entry.key, + `CultCache store ${path} holds record "${entry.key}" as type "${prior.type}", so type "${entry.type}" cannot be written under the same key; remove the record first.`, + ); + } + typeByKey.set(entry.key, entry); + } +} + /** * The one reader of a CultCache single-file store, for this package's store and for any consumer that must read a * store file (an authority bundle, say) rather than decode it itself. A path with nothing at it is an absent, empty, @@ -204,8 +227,9 @@ type DecodedSnapshot = { }; function encodeSnapshot(supplied: CultCacheEnvelope[], arrived: CultCacheEnvelope[], format: StoreFormat): unknown[] { - const replaced = new Set(supplied.map((entry) => `${entry.type}::${entry.key}`)); - const kept = arrived.filter((entry) => !replaced.has(`${entry.type}::${entry.key}`)); + // A key is held once: a supplied record replaces the arrived record under its key whatever the type. + const replaced = new Set(supplied.map((entry) => entry.key)); + const kept = arrived.filter((entry) => !replaced.has(entry.key)); const entries = [...kept, ...supplied]; const catalog = catalogEntriesFor([ ...kept.map((envelope) => ({ envelope, registered: false })), @@ -416,6 +440,7 @@ function decodeStoreFile(data: Uint8Array): Omit throw new Error("CultCache file is not a recognized CultCache MessagePack store."); } + requireUniqueKeys(legacy); const normalized = legacy.map((entry) => ({ ...entry, payload: normalizePayload(entry.payload) })); return { format: STORE_FORMAT_VERSION, envelopes: envelopeArraySchema.parse(normalized).map((entry) => ({ ...entry, payload: documentPayloadFromBytes(entry.payload) })) as CultCacheEnvelope[] }; } @@ -449,9 +474,21 @@ function decodeSnapshot(decoded: unknown): DecodedSnapshot | undefined { } const records = recordsRaw.map(decodeRecord); + requireUniqueKeys(records); return { format: decoded[0], catalogBySchemaId, records }; } +// A store holds a key once, whatever the type: a repeat is refused in a v1 snapshot and in a legacy envelope array alike. +function requireUniqueKeys(records: Array<{ key: string }>): void { + const seen = new Set(); + for (const { key } of records) { + if (seen.has(key)) { + throw new Error(`CultCache record "${key}" appears more than once.`); + } + seen.add(key); + } +} + function decodeCatalogEntry(value: unknown): CultCacheSchemaCatalogEntry { if (!Array.isArray(value)) { throw new Error("CultCache schema catalog entries must be MessagePack arrays."); @@ -479,12 +516,16 @@ function decodeCatalogEntry(value: unknown): CultCacheSchemaCatalogEntry { throw new Error(`CultCache schema catalog entry "${schemaId}" has invalid members.`); } + if (typeof canonicalSchemaJson !== "string") { + throw new Error(`CultCache schema catalog entry "${schemaId}" has an invalid canonical schema.`); + } + return { schemaId, schemaName, schemaVersion: isNonEmptyString(schemaVersion) ? schemaVersion : `${schemaName}.v1`, contentHash: isNonEmptyString(contentHash) ? contentHash : schemaId, - canonicalSchemaJson: typeof canonicalSchemaJson === "string" ? canonicalSchemaJson : "", + canonicalSchemaJson, compatibleSchemaIds, members: members.map(decodeCatalogMember), }; diff --git a/packages/cultcache-ts/test/cult-cache.test.ts b/packages/cultcache-ts/test/cult-cache.test.ts index de0cad79..d7780482 100644 --- a/packages/cultcache-ts/test/cult-cache.test.ts +++ b/packages/cultcache-ts/test/cult-cache.test.ts @@ -143,6 +143,33 @@ test("CultCache supports registry bootstrap, global documents, and lookup by nam }); }); +test("a put that would land a second type under a persisted key is refused, the cache and the file are unchanged, and the store reloads", async () => { + const alpha = defineDocumentType({ type: "alpha", schema: z.object({ v: z.number() }) }); + const beta = defineDocumentType({ type: "beta", schema: z.object({ v: z.number() }) }); + const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-ts-")), "cache.msgpack"); + const registry = defineDocumentRegistry(alpha, beta); + const open = () => CultCache.builder().withRegistry(registry).withGenericStore(new SingleFileMessagePackBackingStore(storePath)).build(); + + const cache = open(); + await cache.put(alpha, "shared", { v: 1 }); + const before = [...(await readFile(storePath))]; + await assert.rejects(() => cache.put(beta, "shared", { v: 2 }), SchemaConflictError); + assert.deepEqual([...(await readFile(storePath))], before); + assert.deepEqual(cache.get(alpha, "shared"), { v: 1 }); + assert.equal(cache.get(beta, "shared"), undefined); + + const reloaded = open(); + await reloaded.pullAllBackingStores(); + assert.deepEqual(reloaded.get(alpha, "shared"), { v: 1 }); + + assert.equal(await cache.delete(alpha, "shared"), true); + await cache.put(beta, "shared", { v: 2 }); + const retyped = open(); + await retyped.pullAllBackingStores(); + assert.equal(retyped.get(alpha, "shared"), undefined); + assert.deepEqual(retyped.get(beta, "shared"), { v: 2 }); +}); + test("CultCache can register name and index lookups after entries already exist", async () => { const noteDocument = defineDocumentType({ type: "note", @@ -2582,6 +2609,20 @@ test("SingleFileMessagePackBackingStore replaces a file exactly when it reads", } }); +// One key is held once: a push of a second type under a stored key is refused, so the writer cannot emit a store its own reader refuses. +test("SingleFileMessagePackBackingStore push refuses a second type under a stored key", async () => { + const seed = CultCache.builder().withDocumentType(ownsId).withDocumentType(listsId).build(); + await seed.put(ownsId, "k", { name: "owner" }); + await seed.put(listsId, "k", { name: "lister" }); + const file = join(await mkdtemp(join(tmpdir(), "cultcache-push-key-")), "store.msgpack"); + const store = new SingleFileMessagePackBackingStore(file); + await store.push(seed.getRequiredEnvelope(ownsId, "k")); + await assert.rejects(() => store.push(seed.getRequiredEnvelope(listsId, "k")), SchemaConflictError); + const records = (decode(await readFile(file)) as unknown[][])[2] as unknown[][]; + assert.deepEqual(records.map((record) => [record[0], record[1]]), [["k", "id.owned"]]); + assert.equal((await store.pullAll()).length, 1); +}); + // Owner beats lister: a definition that owns an id and one that lists it as compatible register together in either order. A record // under the id is the owner's, and a lister's only when nothing owns it; two listers and no owner name no single definition. const ownsId = defineDocumentType({ type: "tests.owns", schema: z.object({ name: z.string() }), schemaId: "id.owned", schemaName: "tests.owns" }); diff --git a/packages/cultcache-ts/test/store-file.test.ts b/packages/cultcache-ts/test/store-file.test.ts index 39ac553f..eb41b77b 100644 --- a/packages/cultcache-ts/test/store-file.test.ts +++ b/packages/cultcache-ts/test/store-file.test.ts @@ -12,6 +12,7 @@ import { decode, encode } from "@msgpack/msgpack"; import { documentPayloadBytes, documentPayloadFromBytes } from "../src/document"; import { SingleFileMessagePackBackingStore } from "../src/single-file-messagepack-backing-store"; +import { SchemaConflictError, StoreUnreadableError } from "../src/store-format"; import type { CultCacheEnvelope } from "../src/types"; import { @@ -571,15 +572,84 @@ const snapshotOf = async (store: SingleFileMessagePackBackingStore): Promise `${entry.type}/${entry.key}:${[...documentPayloadBytes(entry.payload)].at(-1)}`) .sort(); -test("push replaces the record at the same type and key and keeps every other", async () => { +test("push replaces the record under the same key and type and keeps every other; retyping is delete then push", async () => { const directory = await scratch(); try { const store = new SingleFileMessagePackBackingStore(join(directory, "store.cc")); - await store.pushAll([entryOf("a", "t", 1), entryOf("b", "t", 2), entryOf("a", "u", 3)]); + await store.pushAll([entryOf("a", "t", 1), entryOf("b", "t", 2), entryOf("c", "u", 3)]); await store.push(entryOf("a", "t", 9)); - assert.deepEqual(await snapshotOf(store), ["t/a:9", "t/b:2", "u/a:3"]); - await store.push(entryOf("c", "t", 4)); - assert.deepEqual(await snapshotOf(store), ["t/a:9", "t/b:2", "t/c:4", "u/a:3"]); + assert.deepEqual(await snapshotOf(store), ["t/a:9", "t/b:2", "u/c:3"]); + await store.push(entryOf("d", "t", 4)); + assert.deepEqual(await snapshotOf(store), ["t/a:9", "t/b:2", "t/d:4", "u/c:3"]); + await store.delete(entryOf("a", "t")); + await store.push(entryOf("a", "u", 5)); + assert.deepEqual(await snapshotOf(store), ["t/b:2", "t/d:4", "u/a:5", "u/c:3"]); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("a push that would land a second type under an existing key is refused and the file is byte for byte unchanged", async () => { + const directory = await scratch(); + try { + const path = join(directory, "store.cc"); + const store = new SingleFileMessagePackBackingStore(path); + await store.pushAll([entryOf("k", "t", 1), entryOf("m", "t", 2)]); + const before = [...(await readFile(path))]; + await assert.rejects( + () => store.push(entryOf("k", "u", 9)), + (error: unknown) => error instanceof SchemaConflictError && error.recordKey === "k" && /holds record "k" as type "t"/u.test(error.message), + ); + assert.deepEqual([...(await readFile(path))], before); + assert.deepEqual(await snapshotOf(new SingleFileMessagePackBackingStore(path)), ["t/k:1", "t/m:2"]); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("a pushAll batch that carries one key under two types is refused and writes nothing", async () => { + const directory = await scratch(); + try { + const path = join(directory, "store.cc"); + const store = new SingleFileMessagePackBackingStore(path); + await assert.rejects(() => store.pushAll([entryOf("k", "t", 1), entryOf("k", "u", 2)]), SchemaConflictError); + assert.equal((await readdir(directory)).includes("store.cc"), false); + await store.pushAll([entryOf("a", "t", 1)]); + const before = [...(await readFile(path))]; + await assert.rejects(() => store.pushAll([entryOf("k", "t", 1), entryOf("k", "u", 2)]), SchemaConflictError); + assert.deepEqual([...(await readFile(path))], before); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("a pushAll batch that carries one key twice under one type keeps the last and the file reads back with one record", async () => { + const directory = await scratch(); + try { + const path = join(directory, "store.cc"); + await new SingleFileMessagePackBackingStore(path).pushAll([entryOf("k", "t", 1), entryOf("a", "t", 3), entryOf("k", "t", 2)]); + assert.deepEqual(await snapshotOf(new SingleFileMessagePackBackingStore(path)), ["t/a:3", "t/k:2"]); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("a legacy envelope array that holds a key under two types is refused on open, as a v1 store is", async () => { + const directory = await scratch(); + try { + const path = join(directory, "legacy.cc"); + const bytes = encode([ + { key: "a", type: "t", payload: { n: 1 }, storedAt: "2026-10-20T00:00:00Z" }, + { key: "a", type: "u", payload: { n: 2 }, storedAt: "2026-10-20T00:00:00Z" }, + ]); + await writeFile(path, bytes); + const store = new SingleFileMessagePackBackingStore(path); + await assert.rejects( + () => store.pullAll(), + (error: unknown) => error instanceof StoreUnreadableError && /"a" appears more than once/u.test(error.message), + ); + await assert.rejects(() => store.push(entryOf("b")), StoreUnreadableError); + assert.deepEqual([...(await readFile(path))], [...bytes]); } finally { await rm(directory, { recursive: true, force: true }); } @@ -589,11 +659,12 @@ test("delete removes only the record at that type and key", async () => { const directory = await scratch(); try { const store = new SingleFileMessagePackBackingStore(join(directory, "store.cc")); - await store.pushAll([entryOf("a", "t", 1), entryOf("b", "t", 2), entryOf("a", "u", 3)]); + await store.pushAll([entryOf("a", "t", 1), entryOf("b", "t", 2), entryOf("c", "u", 3)]); await store.delete(entryOf("a", "t")); - assert.deepEqual(await snapshotOf(store), ["t/b:2", "u/a:3"]); + assert.deepEqual(await snapshotOf(store), ["t/b:2", "u/c:3"]); await store.delete(entryOf("zzz", "t")); - assert.deepEqual(await snapshotOf(store), ["t/b:2", "u/a:3"]); + await store.delete(entryOf("c", "t")); + assert.deepEqual(await snapshotOf(store), ["t/b:2", "u/c:3"]); } finally { await rm(directory, { recursive: true, force: true }); } diff --git a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs index 55fd7584..0acee656 100644 --- a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs +++ b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs @@ -310,7 +310,7 @@ public static class CultDocumentMessagePackSerialization ///

    /// Reads a store file: exactly one complete MessagePack array of three slots (header, schema catalog, records), or an empty /// array, which is an empty store. A truncated file, bytes after the array, a value that is not an array, a first slot that - /// is not a header string, a store with fewer or more slots, a record whose schema the catalog does not publish, or a body + /// is not a header string, a store with fewer or more slots, a record whose schema the catalog does not publish, two records under one key, or a body /// that does not decode are refused with /// , so a reader never takes part of a file for the whole and never skips a slot /// it does not understand. The rewriting paths use this same reader as their verdict on whether a file may be replaced. @@ -366,6 +366,7 @@ public static class CultDocumentMessagePackSerialization } RequirePublishedSchemas(snapshot); + RequireUniqueKeys(snapshot); return (snapshot, catalogBytes, recordBytes); } catch (CultStoreUnreadableException) @@ -397,6 +398,17 @@ public static class CultDocumentMessagePackSerialization } } + // A key names one record: identity is the key string alone, compared by ordinal code units (no case folding, no normalisation). + private static void RequireUniqueKeys(CultPersistedStoreSnapshot snapshot) + { + var seen = new HashSet(StringComparer.Ordinal); + foreach (var record in snapshot.Records) + { + if (!seen.Add(record.Key)) + throw new CultStoreUnreadableException($"Record '{record.Key}' appears more than once."); + } + } + // Slot 0 of a non-empty store, read before anything after it: a binary or nil stamp is refused as such, and a string stamp is // judged by the caller's rule when it passes one. Bytes that do not open with an array and a string are left to the full read. private static void JudgeStamp(byte[] payload, Action? requireReadable) diff --git a/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs b/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs index 4aef72a1..3ee64e07 100644 --- a/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs +++ b/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs @@ -74,6 +74,18 @@ namespace GameCult.Caching.Tests return path; } + [Test] + public void ADuplicateKeyIsRefusedNamingTheKeyAndTheFile() + { + var path = Path.Combine(_directory, "dup.cc"); + File.WriteAllBytes(path, File.ReadAllBytes(Path.Combine(VectorRoot(), "dup-key-same-schema-v3.bin"))); + + Assert.That(() => CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }), + Throws.TypeOf() + .With.Property(nameof(CultStoreUnreadableException.Path)).EqualTo(path) + .And.Message.Contains("more than once")); + } + [TestCaseSource(nameof(Vectors))] public void OpenReadsExactlyTheFilesTheVectorsSayItReads(string vector, bool reads) { diff --git a/tests/vectors/document-variants-c2a/readability/distinct-keys-case-v3.bin b/tests/vectors/document-variants-c2a/readability/distinct-keys-case-v3.bin new file mode 100644 index 00000000..031d29fb --- /dev/null +++ b/tests/vectors/document-variants-c2a/readability/distinct-keys-case-v3.bin @@ -0,0 +1 @@ +“²cultcache.store.v3‘—¬vectors.item¬vectors.item¯vectors.item.v1¬vectors.itemÙL{"schemaName":"vectors.item","schemaVersion":"vectors.item.v1","members":[]}‘¬vectors.item�“”¥alpha¬vectors.item¹2026-09-29T00:00:00+00:00Ä’¥alpha”¤beta¬vectors.item¹2026-09-29T00:00:01+00:00Ä’¤beta”¥Alpha¬vectors.item¹2026-09-29T00:00:00+00:00Ä’¥alpha \ No newline at end of file diff --git a/tests/vectors/document-variants-c2a/readability/distinct-keys-nfd-v3.bin b/tests/vectors/document-variants-c2a/readability/distinct-keys-nfd-v3.bin new file mode 100644 index 00000000..ba97c3cd --- /dev/null +++ b/tests/vectors/document-variants-c2a/readability/distinct-keys-nfd-v3.bin @@ -0,0 +1 @@ +“²cultcache.store.v3‘—¬vectors.item¬vectors.item¯vectors.item.v1¬vectors.itemÙL{"schemaName":"vectors.item","schemaVersion":"vectors.item.v1","members":[]}‘¬vectors.item�’”¢Ã©¬vectors.item¹2026-09-29T00:00:00+00:00Ä’¥alpha”£eÌ�¬vectors.item¹2026-09-29T00:00:01+00:00Ä’¤beta \ No newline at end of file diff --git a/tests/vectors/document-variants-c2a/readability/dup-key-different-payload-v3.bin b/tests/vectors/document-variants-c2a/readability/dup-key-different-payload-v3.bin new file mode 100644 index 00000000..ac4744fc --- /dev/null +++ b/tests/vectors/document-variants-c2a/readability/dup-key-different-payload-v3.bin @@ -0,0 +1 @@ +“²cultcache.store.v3‘—¬vectors.item¬vectors.item¯vectors.item.v1¬vectors.itemÙL{"schemaName":"vectors.item","schemaVersion":"vectors.item.v1","members":[]}‘¬vectors.item�“”¥alpha¬vectors.item¹2026-09-29T00:00:00+00:00Ä’¥alpha”¤beta¬vectors.item¹2026-09-29T00:00:01+00:00Ä’¤beta”¥alpha¬vectors.item¹2026-09-29T00:00:00+00:00Ä’¥alpha \ No newline at end of file diff --git a/tests/vectors/document-variants-c2a/readability/dup-key-other-schema-v3.bin b/tests/vectors/document-variants-c2a/readability/dup-key-other-schema-v3.bin new file mode 100644 index 00000000..b28cb1d7 --- /dev/null +++ b/tests/vectors/document-variants-c2a/readability/dup-key-other-schema-v3.bin @@ -0,0 +1 @@ +“²cultcache.store.v3’—¬vectors.item¬vectors.item¯vectors.item.v1¬vectors.itemÙL{"schemaName":"vectors.item","schemaVersion":"vectors.item.v1","members":[]}‘¬vectors.item�—­vectors.other­vectors.other°vectors.other.v1­vectors.otherÙN{"schemaName":"vectors.other","schemaVersion":"vectors.other.v1","members":[]}‘­vectors.other�“”¥alpha¬vectors.item¹2026-09-29T00:00:00+00:00Ä’¥alpha”¥alpha­vectors.other¹2026-09-29T00:00:00+00:00Ä’¥alpha”¤beta¬vectors.item¹2026-09-29T00:00:01+00:00Ä’¤beta \ No newline at end of file diff --git a/tests/vectors/document-variants-c2a/readability/dup-key-same-schema-v3.bin b/tests/vectors/document-variants-c2a/readability/dup-key-same-schema-v3.bin new file mode 100644 index 00000000..250b432e --- /dev/null +++ b/tests/vectors/document-variants-c2a/readability/dup-key-same-schema-v3.bin @@ -0,0 +1 @@ +“²cultcache.store.v3‘—¬vectors.item¬vectors.item¯vectors.item.v1¬vectors.itemÙL{"schemaName":"vectors.item","schemaVersion":"vectors.item.v1","members":[]}‘¬vectors.item�“”¥alpha¬vectors.item¹2026-09-29T00:00:00+00:00Ä’¥alpha”¤beta¬vectors.item¹2026-09-29T00:00:01+00:00Ä’¤beta”¥alpha¬vectors.item¹2026-09-29T00:00:00+00:00Ä’¥alpha \ No newline at end of file diff --git a/tests/vectors/document-variants-c2a/readability/generate.py b/tests/vectors/document-variants-c2a/readability/generate.py index 25e12496..fad89a24 100644 --- a/tests/vectors/document-variants-c2a/readability/generate.py +++ b/tests/vectors/document-variants-c2a/readability/generate.py @@ -5,6 +5,7 @@ A file may be replaced by a flush or a commit exactly when the runtime's own rea covers open, flush and commit. Each vector is the bytes named; the trailing and truncated ones are cut from v3-base. """ import pathlib +import unicodedata import msgpack @@ -12,12 +13,21 @@ here = pathlib.Path(__file__).resolve().parent whole = (here.parent / "v3-base.msgpack").read_bytes() pack = lambda value: msgpack.packb(value, use_bin_type=True) base = msgpack.unpackb(whole, raw=False) +# A second published schema, and a record of it under the key alpha. +other_entry = ["vectors.other", "vectors.other", "vectors.other.v1", "vectors.other", '{"schemaName":"vectors.other","schemaVersion":"vectors.other.v1","members":[]}', ["vectors.other"], []] +other_record = ["alpha", "vectors.other"] + base[2][0][2:] +# The same visible key as U+00E9 (NFC) and U+0065 U+0301 (NFD): equal after normalisation, unequal ordinally. +nfc, nfd = "\u00e9", "e\u0301" +assert unicodedata.normalize("NFC", nfd) == unicodedata.normalize("NFC", nfc) and nfc != nfd +assert "Alpha".casefold() == "alpha" and "Alpha" != "alpha" vectors = { "zero-byte": b"", "empty-array": pack([]), # A legacy envelope array: an array of maps. Rust, TypeScript and Python read it; C# has no legacy reader. "legacy-envelopes": pack([{"key": "old", "type": "old.type", "payload": pack([]), "storedAt": "2026-01-01T00:00:00Z"}]), + # A legacy envelope array holding key a under two types: a key is one record, whatever the type. Refused like a v3 store with a shared key. + "legacy-dup-key": pack([{"key": "a", "type": "old.one", "payload": pack([]), "storedAt": "2026-01-01T00:00:00Z"}, {"key": "a", "type": "old.two", "payload": pack([]), "storedAt": "2026-01-01T00:00:00Z"}]), "legacy-trailing": None, # filled below: the legacy array plus bytes after it "ints": pack([1, 2, 3]), "nil-first": pack([None, "x"]), @@ -55,6 +65,16 @@ vectors = { "variant-slot-v3": pack([base[0], base[1], base[2] + [["gamma"] + base[2][0][1:3] + [b"", ["alpha", [[0, [[0, ""]], "", "gamma"]]]]]]), # v3-base with a fourth top-level slot after the records. "extra-top-slot": pack(base + [0]), + # A store holding two records under one key is unreadable, whatever else they agree or differ on. Every record names a + # schema the catalog publishes, so the shared key is the only defect. + "dup-key-same-schema-v3": pack([base[0], base[1], base[2] + [base[2][0]]]), + # Two records under one key, each naming a different published schema: the key alone is the identity. + "dup-key-other-schema-v3": pack([base[0], base[1] + [other_entry], [base[2][0], other_record, base[2][1]]]), + # alpha twice with different payload bytes: last-wins would silently pick one. + "dup-key-different-payload-v3": pack([base[0], base[1], base[2] + [base[2][0][:3] + [b"\x92\xa5alpha\x09"]]]), + # Keys are ordinal strings: no case folding and no Unicode normalisation, so these are distinct keys and the store reads. + "distinct-keys-case-v3": pack([base[0], base[1], base[2] + [["Alpha"] + base[2][0][1:]]]), + "distinct-keys-nfd-v3": pack([base[0], base[1], [[nfc] + base[2][0][1:], [nfd] + base[2][1][1:]]]), } vectors["legacy-trailing"] = vectors["legacy-envelopes"] + b"\x01\x02\x03" for name, data in vectors.items(): diff --git a/tests/vectors/document-variants-c2a/readability/legacy-dup-key.bin b/tests/vectors/document-variants-c2a/readability/legacy-dup-key.bin new file mode 100644 index 00000000..c2b117cc --- /dev/null +++ b/tests/vectors/document-variants-c2a/readability/legacy-dup-key.bin @@ -0,0 +1 @@ +’„£key¡a¤type§old.one§payloadÄ�¨storedAt´2026-01-01T00:00:00Z„£key¡a¤type§old.two§payloadÄ�¨storedAt´2026-01-01T00:00:00Z \ No newline at end of file diff --git a/tests/vectors/document-variants-c2a/readability/manifest.txt b/tests/vectors/document-variants-c2a/readability/manifest.txt index dd1fc4b4..6d7af19d 100644 --- a/tests/vectors/document-variants-c2a/readability/manifest.txt +++ b/tests/vectors/document-variants-c2a/readability/manifest.txt @@ -1,9 +1,11 @@ # Whether each runtime's own reader opens the file. A flush and a commit replace a file exactly when it reads; where a runtime has a soft flush (TypeScript, Rust) it leaves a file that reads byte for byte and refuses one that does not. +# A store holding two records under one key is unreadable; keys are ordinal strings (no case folding, no normalisation), so distinct spellings are distinct keys. # Columns: file, csharp, rust, typescript, python. `../v3-base.msgpack` is the one valid store. ../v3-base.msgpack reads reads reads reads zero-byte.bin refuses refuses refuses refuses empty-array.bin reads reads reads reads legacy-envelopes.bin refuses reads reads reads +legacy-dup-key.bin refuses refuses refuses refuses legacy-trailing.bin refuses refuses refuses refuses ints.bin refuses refuses refuses refuses nil-first.bin refuses refuses refuses refuses @@ -30,3 +32,9 @@ missing-schema-versioned-v3.bin refuses refuses refuses refuses compatible-id-only-v3.bin reads reads reads reads own-id-over-compatible-v3.bin reads reads reads reads compatible-before-owner-v3.bin reads reads reads reads +dup-key-same-schema-v3.bin refuses refuses refuses refuses +dup-key-other-schema-v3.bin refuses refuses refuses refuses +dup-key-different-payload-v3.bin refuses refuses refuses refuses +distinct-keys-case-v3.bin reads reads reads reads +distinct-keys-nfd-v3.bin reads reads reads reads +../../document-variants-c0/current-catalog-bad-canonical.msgpack refuses refuses refuses refuses From 0ebc7d63e8d2f6047b517c29d3199973bec0e70a Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 15:08:01 +0000 Subject: [PATCH 31/93] Variants cut doc: C4 records Nibu's ruling on where hull families live (#111) --- docs/document-variants-cut.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/document-variants-cut.md b/docs/document-variants-cut.md index edf86d6a..606e46b0 100644 --- a/docs/document-variants-cut.md +++ b/docs/document-variants-cut.md @@ -701,6 +701,7 @@ checked by the operator in the editor. - Run `MintElementIds` once over `GameData/Aetheria.cc`. - AetherDb migrations skip variants or use the variant API (Q1a). - Re-author the families listed in section 6. +- Hull families live in each ship's own `.cc`, not in `GameData/Aetheria.cc`, because hulls are mod-owned packages (Nibu's ruling about her data, 2026-11-11, #minds). ### C5. CultNet deltas (C# reference) From 5fa7057ed1158f30a283194fecfbbc4f10561af1 Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 15:58:39 +0000 Subject: [PATCH 32/93] Duplicate-key residuals: changelogs, retype by schema id, Rust CAS retype (#112) --- packages/cultcache-py/CHANGELOG.md | 4 + .../cultcache-py/src/cultcache_py/stores.py | 14 +- packages/cultcache-py/tests/test_cultcache.py | 19 +++ packages/cultcache-rs/CHANGELOG.md | 4 + packages/cultcache-rs/src/lib.rs | 154 +++++++++++++++--- packages/cultcache-ts/CHANGELOG.md | 4 + .../single-file-messagepack-backing-store.ts | 10 +- packages/cultcache-ts/test/cult-cache.test.ts | 49 +++++- unity/org.gamecult.cultlib/CHANGELOG.md | 6 + 9 files changed, 235 insertions(+), 29 deletions(-) diff --git a/packages/cultcache-py/CHANGELOG.md b/packages/cultcache-py/CHANGELOG.md index d440ddca..e0026b8e 100644 --- a/packages/cultcache-py/CHANGELOG.md +++ b/packages/cultcache-py/CHANGELOG.md @@ -9,6 +9,10 @@ ### Breaking +- A single-file store holding two records under one key is unreadable: the v1 reader and the legacy envelope-array reader refuse it with `StoreUnreadableError` (a key is compared by its exact code points, no case folding or normalisation). A catalog slot whose canonical schema is not a string is refused the same way. A store that loaded before can now be refused: repair it with the runtime that wrote the duplicate. +- A `put` or `push_all` that would land a second type under a key the store holds is refused with `SchemaConflictError`, and the file is left as it was. Retype a key by deleting it, then putting the new type. A write replaces the record at a key only when the written type owns it: its catalog entry declares the stored schema id (its own, or one it lists compatible), so a renamed type that lists the old id compatible may replace the record, or it has the stored record's schema name. This runtime does not compare shapes, so a same-name type is never refused for dropping persisted slots as C# refuses it. +- Writes of the same type replace by key: the last record wins, in a batch as in the file. + - `indexes` are unique, as C# `[CultIndex]`: a write, load or `register_index` that leaves two records of a type holding one non-blank value under one alias is refused (`CultCacheError`) naming both keys and the alias, and writes nothing. Index text is the C# canonical text (`True`/`False`, integers as digits, floats as the exact expansion, blank values never indexed), so an empty or whitespace string is no longer a value. A new `roles` map names the many-row selection aliases (never indexed for lookup, same catalog `index_alias`). Classify every `indexes` key per the README before updating. The verdict is the contract, the text is informational. - A single-file store written as `cultcache.store.v3` is read and written: a store the C# runtime marked v3 (it holds a record with an element id: an owned list element with a `[CultElementId]` member) keeps its header here, and is carried through every rewrite with its ids; this runtime never drops the mark. A runtime from before this change refuses a v3 store by name. Rolling back to an older release after a store has been written as v3 is one-way: there is no carry-back, so restore from a copy taken before upgrading. diff --git a/packages/cultcache-py/src/cultcache_py/stores.py b/packages/cultcache-py/src/cultcache_py/stores.py index db24b3bd..e0cfdd7a 100644 --- a/packages/cultcache-py/src/cultcache_py/stores.py +++ b/packages/cultcache-py/src/cultcache_py/stores.py @@ -258,16 +258,26 @@ def _retyped(held: CultCacheEnvelope, written: CultCacheEnvelope) -> SchemaConfl ) +def _written_owns(held: CultCacheEnvelope, written: CultCacheEnvelope) -> bool: + """Whether a write of `written` may replace `held` at its key (C# WrittenOwns): the written type's entry declares the held + record's schema id (its own id or one it lists compatible), or it is the type of the same schema name. The same-name half + stands in for C#'s "reads the published entry whole": this runtime does not compare shapes, so it never refuses a + same-name type for dropping slots.""" + stored_id = _schema_id_for(held) + entry = written.catalog_entry if written.catalog_entry is not None else _default_catalog_entry(written) + return stored_id == entry.schema_id or stored_id in entry.compatible_schema_ids or held.type == written.type + + def _encode_snapshot( supplied: list[CultCacheEnvelope], arrived: list[CultCacheEnvelope], format_version: str ) -> list[Any]: # A key names one record whatever its type, so a supplied record replaces the arrived one by key alone, and only of the - # same type: retyping a key is a removal and an add, so a record of another type under the key refuses the write. + # type that owns it: retyping a key is a removal and an add, so a record another type owns under the key refuses the write. held_by_key = {envelope.key: envelope for envelope in arrived} landing: dict[str, CultCacheEnvelope] = {} for envelope in supplied: for held in (landing.get(envelope.key), held_by_key.get(envelope.key)): - if held is not None and held.type != envelope.type: + if held is not None and not _written_owns(held, envelope): raise _retyped(held, envelope) landing[envelope.key] = envelope supplied = list(landing.values()) diff --git a/packages/cultcache-py/tests/test_cultcache.py b/packages/cultcache-py/tests/test_cultcache.py index 2be0a84b..80abbd4f 100644 --- a/packages/cultcache-py/tests/test_cultcache.py +++ b/packages/cultcache-py/tests/test_cultcache.py @@ -1644,6 +1644,25 @@ class CultCacheTests(unittest.TestCase): store.push(self._rec("second", "k", 1)) self.assertEqual([e.type for e in SingleFileMessagePackBackingStore(path).pull_all()], ["second"]) + def test_a_retype_is_judged_by_the_stored_schema_id_the_written_type_declares_not_by_name(self) -> None: + from cultcache_py import SchemaConflictError + + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "store.msgpack" + store = SingleFileMessagePackBackingStore(path) + store.push(self._writer_record("k", "old", "id-old", self._writer_entry("id-old", "old", "h1", ("id-old",)))) + content = path.read_bytes() + # A renamed type that lists the stored id compatible owns the record, so its write replaces it. + renamed = self._writer_record("k", "new", "id-new", self._writer_entry("id-new", "new", "h2", ("id-new", "id-old"))) + # A type that lists nothing of the stored id and has another name does not. + stranger = self._writer_record("k", "stranger", "id-x", self._writer_entry("id-x", "stranger", "h3", ("id-x",))) + for run in (lambda: store.push(stranger), lambda: store.push_all([stranger])): + with self.assertRaises(SchemaConflictError): + run() + self.assertEqual(path.read_bytes(), content) + store.push(renamed) + self.assertEqual([(e.type, e.schema_id) for e in SingleFileMessagePackBackingStore(path).pull_all()], [("new", "id-new")]) + def test_a_batch_naming_one_key_under_two_types_is_refused_whole(self) -> None: from cultcache_py import SchemaConflictError diff --git a/packages/cultcache-rs/CHANGELOG.md b/packages/cultcache-rs/CHANGELOG.md index 15011491..c32773d9 100644 --- a/packages/cultcache-rs/CHANGELOG.md +++ b/packages/cultcache-rs/CHANGELOG.md @@ -14,6 +14,10 @@ only in git history. - `bool` index and lookup text renders `True` / `False`, as C# does, where it rendered `true` / `false`. - `DatabaseEntry::UNIQUE_INDEXES` replaces `cult_indexes` as the one index declaration and changes type (its members are the unique aliases); a hand implementation overriding `cult_indexes` no longer compiles. - `register_entry_type` over a type that already holds records re-judges them under the new declaration (unique indexes and the global rule) and refuses, changing nothing, when they violate it. A declaration with an index also refuses when a held record cannot be decoded as the new type; a declaration without one does not read the records, so it does not. +- A single-file store holds one record per key. The v1 and legacy readers refuse a store with two records under one key (`CultCacheStoreUnreadable`, the record `Undecodable`): the store does not open, and a write over it refuses and leaves the file. A consumer that holds such a file must repair it with the runtime that wrote it before opening it here. +- Every single-file writer (`push`, `push_all`, `insert_entry_if_absent`, the compare-exchange and snapshot-append family, and the cache's puts and batches) refuses to put a second type under a key the store holds, with `SchemaConflictError` naming the key and both types, writing nothing. Retyping a key is a delete, then an add (one batch may hold both). A write owns the held record when its schema id equals the id the held record is stored under, as C# `WrittenOwns` asks; a type renamed under a stable id therefore replaces its own record, and a write of another type name under another id is refused (`push` of the same type name under another id still replaces the record, as before; `compare_and_swap_entry`, and a `push_all` batch holding one type under two ids, refuse it). Registered types here declare no compatible ids, so equality is the whole test. Writers used to leave two records under the key. +- `compare_and_swap_entry` over a single file refuses an in-place retype (expected `A/k`, replacement `B/k` under another schema id) with the same `SchemaConflictError`, as `compare_exchange` does, where it used to retype the record and return `true`. A lost race still returns `false` first. +- A write of the same type under a held key replaces the record by key, and in one `push_all` the last given of several entries under a key and a schema id is the record, whatever their type names. The redb stores are unchanged and still key by type and key. ### Added diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index 8984cd29..868589a1 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -1187,6 +1187,10 @@ impl SingleFileMessagePackBackingStore { if entries[index] != *expected { return Ok(false); } + // An in-place retype is a retype: the replacement must own the schema id the held record is stored under. + if schema_identity(&entries[index]) != schema_identity(&replacement) { + return Err(retype_conflict(&entries[index], &replacement)); + } entries[index] = replacement; entries.sort_by_key(entry_id); self.write_all_unlocked(&entries)?; @@ -1479,7 +1483,6 @@ impl SingleFileMessagePackBackingStore { } fn write_all_unlocked(&self, entries: &[CultCacheEnvelope]) -> Result<()> { - require_one_record_per_key(entries)?; let failed = |kind| CultCacheStoreWriteFailed { path: self.path.clone(), kind, @@ -1495,7 +1498,8 @@ impl SingleFileMessagePackBackingStore { Err(error) if io_not_found(&error) => DiskStore::absent(), other => other?, }; - let bytes = encode_store_snapshot(entries, &disk) + let entries = settle_one_record_per_key(entries, &disk)?; + let bytes = encode_store_snapshot(&entries, &disk) .and_then(|snapshot| rmp_serde::to_vec(&snapshot).context("failed to encode MessagePack")) .map_err(|error| error.context(failed(CultCacheStoreWriteFailedKind::Rejected)))?; self.replace_unlocked(&bytes) @@ -1773,11 +1777,11 @@ impl CacheBackingStore for SingleFileMessagePackBackingStore { return Ok(()); } - // Of same-type entries sharing a key the last one given is the record; a second type under a key reaches the - // commit guard. + // Of entries sharing a key and a schema id the last one given is the record, whatever their type names (a type + // renamed under a stable id); a second schema id under a key reaches the commit guard. let mut by_id = BTreeMap::<(String, String), &CultCacheEnvelope>::new(); for entry in entries { - by_id.insert(entry_id(entry), entry); + by_id.insert((schema_identity(entry).to_string(), entry.key.clone()), entry); } let mut entries: Vec = by_id.into_values().cloned().collect(); entries.sort_by_key(entry_id); @@ -3738,23 +3742,47 @@ fn require_unique_keys(envelopes: &[CultCacheEnvelope]) -> Result<()> { Ok(()) } -/// The commit guard every single-file write passes: a store holds one record per key, so a snapshot about to be written -/// may not hold a key twice. Two types under a key are a retype, refused with the key and both types (retyping is -/// remove-then-add); the same type twice is a malformed snapshot. Nothing is written. -fn require_one_record_per_key(entries: &[CultCacheEnvelope]) -> Result<()> { - let mut held = HashMap::<&str, &CultCacheEnvelope>::new(); +/// What a record's stored schema id is: the id it carries, else its type (a registered type's id is its entry type). This +/// runtime's registered types declare no compatible ids, so a written record owns a held one exactly when the ids are equal: +/// the question C# `WrittenOwns` asks of the stored id, not a comparison of type names. +fn schema_identity(entry: &CultCacheEnvelope) -> &str { + entry.schema_id.as_deref().unwrap_or(&entry.r#type) +} + +/// The refusal of a write that would retype a key: the written record does not own the schema id the held one is stored under. +/// Retyping is remove-then-add (variants:ruling:cache-refuses-retype). +fn retype_conflict(held: &CultCacheEnvelope, written: &CultCacheEnvelope) -> anyhow::Error { + anyhow::Error::new(SchemaConflictError { + schema_id: schema_identity(written).to_string(), + schema_names: vec![held.r#type.clone(), written.r#type.clone()], + record_key: written.key.clone(), + }) +} + +/// The commit guard every single-file write passes: a store holds one record per key. Two entries under a key whose schema +/// ids differ are a retype and are refused with the key and both types, nothing written. Two under one schema id are one +/// record: a written entry replaces the held one it owns (a type renamed under a stable id), the held one being what the file +/// already carries. +fn settle_one_record_per_key(entries: &[CultCacheEnvelope], disk: &DiskStore) -> Result> { + let arrived: BTreeMap<(String, String), &CultCacheEnvelope> = disk.envelopes.iter().map(|entry| (entry_id(entry), entry)).collect(); + let held = |entry: &CultCacheEnvelope| arrived.get(&entry_id(entry)) == Some(&entry); + let mut settled = Vec::<&CultCacheEnvelope>::new(); + let mut at_key = HashMap::<&str, usize>::new(); for entry in entries { - let Some(first) = held.insert(entry.key.as_str(), entry) else { continue }; - if first.r#type == entry.r#type { - bail!("CultCache snapshot holds record {:?} of type {:?} more than once", entry.key, entry.r#type); + match at_key.get(entry.key.as_str()) { + None => { + at_key.insert(entry.key.as_str(), settled.len()); + settled.push(entry); + } + Some(&slot) if schema_identity(settled[slot]) != schema_identity(entry) => return Err(retype_conflict(settled[slot], entry)), + Some(&slot) => { + if !held(entry) || held(settled[slot]) { + settled[slot] = entry; + } + } } - return Err(anyhow::Error::new(SchemaConflictError { - schema_id: entry.schema_id.clone().unwrap_or_else(|| entry.r#type.clone()), - schema_names: vec![first.r#type.clone(), entry.r#type.clone()], - record_key: entry.key.clone(), - })); } - Ok(()) + Ok(settled.into_iter().cloned().collect()) } /// Decodes a store whose header the caller has already read as a readable one: its envelopes and its catalog. @@ -4372,6 +4400,7 @@ mod tests { ("push", |s, e, _| s.push(&e[2]).map(|()| true)), ("push_all", |s, e, _| s.push_all(&[e[0].clone(), e[2].clone()], PushAllOptions::default()).map(|()| true)), ("insert_entry_if_absent", |s, e, _| s.insert_entry_if_absent(e[2].clone())), + ("compare_and_swap_entry", |s, e, _| s.compare_and_swap_entry(&e[0], e[2].clone())), ("compare_exchange", |s, e, x| s.compare_exchange(std::slice::from_ref(x), &[e[2].clone()])), ("compare_and_swap_batch", |s, e, _| s.compare_and_swap_batch(&[], vec![e[2].clone()])), ("append_if_snapshot_unchanged", |s, e, _| s.append_if_snapshot_unchanged(&[e[0].clone(), e[1].clone()], vec![e[2].clone()])), @@ -6624,6 +6653,93 @@ mod tests { Ok(()) } + // The retype refusal asks what C# WrittenOwns asks: does the written record own the schema id the held one is stored under. + // A type renamed under a stable id owns it, so its write replaces the record; another id does not, and is refused. + #[test] + fn a_write_owning_the_stored_schema_id_replaces_the_record_under_a_new_name() -> Result<()> { + let envelope = |r#type: &str, schema_id: &str, payload: &[u8]| CultCacheEnvelope { + key: "a".into(), + r#type: r#type.into(), + payload: payload.to_vec(), + stored_at: "2026-09-30T00:00:00Z".into(), + schema_id: Some(schema_id.into()), + }; + let temp = tempfile::tempdir()?; + let path = temp.path().join("store.msgpack"); + store_file(&path, &[("t.s", "t.old")], &[("a", "t.s", b"one".to_vec())])?; + let before = std::fs::read(&path)?; + let error = SingleFileMessagePackBackingStore::new(&path).push(&envelope("t.other", "t.other", b"x")).unwrap_err(); + assert_eq!(retype_refusal(&error).record_key, "a"); + assert_eq!(std::fs::read(&path)?, before); + + SingleFileMessagePackBackingStore::new(&path).push(&envelope("t.s", "t.s", b"two"))?; + let held = SingleFileMessagePackBackingStore::new(&path).pull_all()?; + assert_eq!(held.len(), 1); + assert_eq!((held[0].r#type.as_str(), held[0].payload.as_slice()), ("t.s", b"two".as_slice())); + Ok(()) + } + + // Where a held record and a written one share a schema id under different type names, the written one is the record + // whichever name sorts first: the writers hand settle_one_record_per_key their entries sorted by type name. + #[test] + fn a_renamed_type_replaces_the_held_record_whether_its_name_sorts_before_or_after() -> Result<()> { + for new_name in ["a.new", "z.new"] { + let temp = tempfile::tempdir()?; + let path = temp.path().join("store.msgpack"); + store_file(&path, &[("t.s", "t.old")], &[("a", "t.s", b"one".to_vec())])?; + let written = CultCacheEnvelope { + key: "a".into(), + r#type: new_name.into(), + payload: b"two".to_vec(), + stored_at: "2026-09-30T00:00:00Z".into(), + schema_id: Some("t.s".into()), + }; + SingleFileMessagePackBackingStore::new(&path).push(&written)?; + let held = SingleFileMessagePackBackingStore::new(&path).pull_all()?; + assert_eq!(held.len(), 1, "{new_name}"); + assert_eq!(held[0].payload, b"two".to_vec(), "{new_name}"); + } + Ok(()) + } + + // In one push_all, entries under a key and a schema id with different type names resolve by the order given, not by + // the order of the names. + #[test] + fn push_all_resolves_same_id_entries_with_different_names_by_the_order_given() -> Result<()> { + let envelope = |r#type: &str, payload: &[u8]| CultCacheEnvelope { + key: "a".into(), + r#type: r#type.into(), + payload: payload.to_vec(), + stored_at: "2026-09-30T00:00:00Z".into(), + schema_id: Some("t.s".into()), + }; + for (first, second) in [("a.name", "b.name"), ("b.name", "a.name")] { + let temp = tempfile::tempdir()?; + let path = temp.path().join("store.msgpack"); + let mut store = SingleFileMessagePackBackingStore::new(&path); + store.push_all(&[envelope(first, b"first"), envelope(second, b"second")], PushAllOptions::default())?; + let held = store.pull_all()?; + assert_eq!(held.len(), 1, "{first} then {second}"); + assert_eq!(held[0].payload, b"second".to_vec(), "{first} then {second}"); + } + Ok(()) + } + + // Of same-type entries sharing a key in one push_all the last one given is the record. + #[test] + fn push_all_keeps_the_last_of_two_same_type_entries_under_a_key() -> Result<()> { + let mut cache = CultCache::new(); + cache.register_registry(TestEntries)?; + let (first, _) = cache.prepare_entry("k", &settings_one())?; + let (last, _) = cache.prepare_entry("k", &Settings { theme: "red".to_string(), retries: 2 })?; + let temp = tempfile::tempdir()?; + let path = temp.path().join("store.cc"); + let mut store = SingleFileMessagePackBackingStore::new(&path); + store.push_all(&[first, last.clone()], PushAllOptions::default())?; + assert_eq!(store.pull_all()?.into_iter().map(|e| e.payload).collect::>(), vec![last.payload]); + Ok(()) + } + #[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] #[cultcache(type = "tests.stable", schema = "tests.old_name")] struct StableId { diff --git a/packages/cultcache-ts/CHANGELOG.md b/packages/cultcache-ts/CHANGELOG.md index 1d0e81a2..8a4dd9c4 100644 --- a/packages/cultcache-ts/CHANGELOG.md +++ b/packages/cultcache-ts/CHANGELOG.md @@ -14,6 +14,10 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following an empty string is no longer a value. `registerIndex` over held duplicates installs nothing. A new `roles` map names the many-row selection aliases (never indexed for lookup, same catalog `indexAlias`). The per-key migration rule is in the README; classify every key before the CultLib your tools load is updated. The refusal verdict is the contract, its text is informational. +- The single-file reader (v1 and the legacy envelope array) refuses a store that holds two records under one key as `StoreUnreadableError`; a store that opened before may now be refused. Repair it by removing the extra record with the runtime that wrote it. +- A `put`, `push` or `pushAll` that would land a second type under a held key (against the file, or within one batch) is refused with `SchemaConflictError`, and nothing is written. The written type may replace the record when its catalog entry carries the stored schema id as its own or lists it compatible (a type renamed under a stable id restamps the record, as C# does); otherwise retyping a key is a delete, then a put. The C# allowance for an undeclared older id of the same schema name that reads whole is not ported: a same-named type keeps passing, including one that drops persisted slots, which C# refuses. +- Same-type writes replace by key; in one `pushAll` batch the last entry for a key wins. +- A store whose canonical-schema catalog slot is not a string is refused as `StoreUnreadableError`. ### Changed diff --git a/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts b/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts index 4fdb2fb6..3f484ad8 100644 --- a/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts +++ b/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts @@ -138,6 +138,14 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore { } } +// As C# WrittenOwns' declared half: the written type owns the stored schema id when its catalog entry carries that id as its own +// or lists it compatible, so a type renamed under a stable id restamps the record it already holds. (The C# half that lets an +// undeclared older id of the same schema name through when it reads whole is not ported; a same-named type keeps passing.) +function writtenOwns(written: CultCacheEnvelope, storedId: string): boolean { + const entry = written.catalogEntry; + return entry !== undefined && (entry.schemaId === storedId || (entry.compatibleSchemaIds ?? []).includes(storedId)); +} + // A store holds a key once, and the reader refuses a file that holds it twice, so a write that would put a second type under a // key (against a record on disk, or within its own batch) is refused before anything is written. Retyping a record is a delete // of the old type followed by a put of the new one. @@ -148,7 +156,7 @@ function requireOneTypePerKey(path: string, supplied: CultCacheEnvelope[], held: } for (const entry of supplied) { const prior = typeByKey.get(entry.key); - if (prior !== undefined && prior.type !== entry.type) { + if (prior !== undefined && prior.type !== entry.type && !writtenOwns(entry, schemaIdForEnvelope(prior))) { throw new SchemaConflictError( schemaIdForEnvelope(entry), [prior.type, entry.type], diff --git a/packages/cultcache-ts/test/cult-cache.test.ts b/packages/cultcache-ts/test/cult-cache.test.ts index d7780482..2675545e 100644 --- a/packages/cultcache-ts/test/cult-cache.test.ts +++ b/packages/cultcache-ts/test/cult-cache.test.ts @@ -14,7 +14,7 @@ import { z } from "zod"; import { CultCache } from "../src/cult-cache"; import { inspectCultCacheBytes } from "../src/cult-cache-inspector"; import { defineDocumentRegistry, defineDocumentType, documentPayloadBytes, documentPayloadFromBytes } from "../src/document"; -import { SingleFileMessagePackBackingStore, readStoreBytes } from "../src/single-file-messagepack-backing-store"; +import { SingleFileMessagePackBackingStore, readSingleFileStore, readStoreBytes } from "../src/single-file-messagepack-backing-store"; import { SchemaConflictError, StoreUnreadableError, refuseUnreadStoreBytes } from "../src/store-format"; import type { AnyCultCacheDocumentDefinition, CacheBackingStore, CultCacheEnvelope, CultCacheSchema } from "../src/types"; @@ -170,6 +170,36 @@ test("a put that would land a second type under a persisted key is refused, the assert.deepEqual(retyped.get(beta, "shared"), { v: 2 }); }); +test("a put by a type renamed under the stored id, which lists that id compatible, restamps the record; an unrelated type is refused", async () => { + const old = defineDocumentType({ type: "ren.old", schema: z.object({ v: z.number() }), schemaId: "ren.id.v1", schemaName: "ren.old" }); + const renamed = defineDocumentType({ + type: "ren.new", + schema: z.object({ v: z.number() }), + schemaId: "ren.id.v2", + schemaName: "ren.new", + compatibleSchemaIds: ["ren.id.v2", "ren.id.v1"], + }); + const other = defineDocumentType({ type: "ren.other", schema: z.object({ v: z.number() }), schemaId: "ren.id.other", schemaName: "ren.other" }); + const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-ts-")), "cache.msgpack"); + const open = (...types: (typeof old)[]) => + types + .reduce((builder, type) => builder.withDocumentType(type), CultCache.builder()) + .withGenericStore(new SingleFileMessagePackBackingStore(storePath)) + .build(); + + await open(old).put(old, "k", { v: 1 }); + const cache = open(renamed, other as unknown as typeof old); + await cache.pullAllBackingStores(); + assert.deepEqual(cache.get(renamed, "k"), { v: 1 }); + await assert.rejects(() => cache.put(other, "k", { v: 9 }), SchemaConflictError); + await cache.put(renamed, "k", { v: 2 }); + + const reloaded = open(renamed); + await reloaded.pullAllBackingStores(); + assert.deepEqual(reloaded.get(renamed, "k"), { v: 2 }); + assert.equal(readSingleFileStore(storePath).envelopes.filter((envelope) => envelope.key === "k").length, 1); +}); + test("CultCache can register name and index lookups after entries already exist", async () => { const noteDocument = defineDocumentType({ type: "note", @@ -2609,17 +2639,22 @@ test("SingleFileMessagePackBackingStore replaces a file exactly when it reads", } }); -// One key is held once: a push of a second type under a stored key is refused, so the writer cannot emit a store its own reader refuses. -test("SingleFileMessagePackBackingStore push refuses a second type under a stored key", async () => { +// One key is held once: a push whose type does not own the stored schema id is refused, so the writer cannot emit a store its own +// reader refuses; one whose type owns it (its own id, or one it lists compatible, as C# WrittenOwns) restamps the record in place. +test("SingleFileMessagePackBackingStore push asks whether the written type owns the stored id, not what the types are called", async () => { const seed = CultCache.builder().withDocumentType(ownsId).withDocumentType(listsId).build(); await seed.put(ownsId, "k", { name: "owner" }); await seed.put(listsId, "k", { name: "lister" }); const file = join(await mkdtemp(join(tmpdir(), "cultcache-push-key-")), "store.msgpack"); const store = new SingleFileMessagePackBackingStore(file); - await store.push(seed.getRequiredEnvelope(ownsId, "k")); - await assert.rejects(() => store.push(seed.getRequiredEnvelope(listsId, "k")), SchemaConflictError); - const records = (decode(await readFile(file)) as unknown[][])[2] as unknown[][]; - assert.deepEqual(records.map((record) => [record[0], record[1]]), [["k", "id.owned"]]); + const idsOf = async () => ((decode(await readFile(file)) as unknown[][])[2] as unknown[][]).map((record) => [record[0], record[1]]); + + await store.push(seed.getRequiredEnvelope(listsId, "k")); + await assert.rejects(() => store.push(seed.getRequiredEnvelope(ownsId, "k")), SchemaConflictError); + assert.deepEqual(await idsOf(), [["k", "id.lister"]]); + + await store.pushAll([seed.getRequiredEnvelope(listsId, "k")]); + await store.push(seed.getRequiredEnvelope(listsId, "k")); assert.equal((await store.pullAll()).length, 1); }); diff --git a/unity/org.gamecult.cultlib/CHANGELOG.md b/unity/org.gamecult.cultlib/CHANGELOG.md index b5ff6419..225bb3ae 100644 --- a/unity/org.gamecult.cultlib/CHANGELOG.md +++ b/unity/org.gamecult.cultlib/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this package are documented in this file. +## Unreleased + +### Breaking + +- A single-file store holding two records under one key is unreadable: the reader refuses it with `CultStoreUnreadableException` naming the key (keys compare by ordinal code units, no case folding or normalisation), so a cache, `ReadStoreSnapshot` and a flush refuse the file and leave it as it is. Before, the later record silently replaced the earlier. A directory store reads its manifest through the same reader and refuses a duplicate key the same way. Repair the duplicate with the runtime that wrote it before reading the store here. + ## [3.0.0] - 2026-11-11 ### Breaking From 8beb972c68a3647b785f4da07532e078e6ed07a5 Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 16:05:36 +0000 Subject: [PATCH 33/93] cultnet-ts test: reconnecting client sends only after its own handshake (#113) --- packages/cultnet-ts/test/rudp-server.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/cultnet-ts/test/rudp-server.test.ts b/packages/cultnet-ts/test/rudp-server.test.ts index 6ba976d3..0f913055 100644 --- a/packages/cultnet-ts/test/rudp-server.test.ts +++ b/packages/cultnet-ts/test/rudp-server.test.ts @@ -115,6 +115,8 @@ test("a client reconnecting from the same endpoint replaces only its own session assert.throws(() => server.send(oldPeer, "schema", enc("stale")), /not connected/); assert.equal(server.peers.length, 2); + // The server accepting is not the client hearing it: send only once the client's new session is connected. + await waitFor(() => a.connection.connected, "the reconnecting client's handshake"); a.connection.send("schema", enc("after")); b.connection.send("schema", enc("still")); await waitFor(() => a.received.includes("re:after") && b.received.includes("re:still"), "replies after the reconnect"); From f4eb2a04d1152d9df77211c6c0c839d3013aad7c Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 17:25:05 +0000 Subject: [PATCH 34/93] Readability manifest: legacy-catalog c0 rows and directory-at-path, walked by all four runtimes (#114) --- packages/cultcache-py/tests/test_cultcache.py | 15 ++++- packages/cultcache-rs/src/lib.rs | 16 +++++- packages/cultcache-ts/test/cult-cache.test.ts | 17 +++++- .../StoreReadabilityTests.cs | 57 +++++++++++++++---- .../CultMeshSingleFileReadabilityTests.cs | 29 ++++++++-- .../readability/manifest.txt | 5 ++ 6 files changed, 114 insertions(+), 25 deletions(-) diff --git a/packages/cultcache-py/tests/test_cultcache.py b/packages/cultcache-py/tests/test_cultcache.py index 80abbd4f..a8c1dbe7 100644 --- a/packages/cultcache-py/tests/test_cultcache.py +++ b/packages/cultcache-py/tests/test_cultcache.py @@ -1567,15 +1567,19 @@ class CultCacheTests(unittest.TestCase): # Every vector in the folder has a manifest row. self.assertEqual( sorted(path.name for path in root.glob("*.bin")), - sorted(vector for vector, *_ in rows if not vector.startswith("..")), + sorted(vector for vector, *_ in rows if not vector.startswith(("..", "@"))), ) envelopes = SingleFileMessagePackBackingStore(self._C2A_VECTORS / "v3-base.msgpack").pull_all() for vector, _, _, _, python in rows: - content = (root / vector).read_bytes() + # "@directory": the walker makes an empty directory at the store path; it is refused and left untouched. + content = None if vector == "@directory" else (root / vector).read_bytes() for operation in ("pull_all", "push_all", "push", "delete"): with tempfile.TemporaryDirectory() as tmp: store_path = Path(tmp) / "store.msgpack" - store_path.write_bytes(content) + if content is None: + store_path.mkdir() + else: + store_path.write_bytes(content) store = SingleFileMessagePackBackingStore(store_path) run = { "pull_all": store.pull_all, @@ -1598,6 +1602,11 @@ class CultCacheTests(unittest.TestCase): self.assertIn(envelopes[0].key, keys, f"{vector} {operation}") else: self.assertNotIn(envelopes[0].key, keys, f"{vector} {operation}") + elif content is None: + # A path that cannot be reached as a file stays an OSError (_read_store): not a store that failed to decode. + with self.assertRaises(OSError, msg=f"{vector} {operation}"): + run() + self.assertTrue(store_path.is_dir() and not any(store_path.iterdir()), f"{vector} {operation} touched the directory") else: with self.assertRaises(StoreUnreadableError, msg=f"{vector} {operation}") as refused: run() diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index 868589a1..0c872d91 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -6140,16 +6140,22 @@ mod tests { for line in manifest.lines().filter(|line| !line.is_empty() && !line.starts_with('#')) { let cells: Vec<&str> = line.split_whitespace().collect(); let (vector, reads) = (cells[0], cells[2] == "reads"); - if !vector.starts_with("..") { + if !vector.starts_with("..") && !vector.starts_with('@') { listed.push(vector.to_string()); } - let bytes = std::fs::read(root.join(vector))?; + // `@directory`: the walker puts an empty directory at the store path; it is refused and left untouched. + let directory = vector == "@directory"; + let bytes = if directory { Vec::new() } else { std::fs::read(root.join(vector))? }; // A v3 store keeps its marker through a rewrite; every other file that reads is written v1. let header = if vector.contains("v3") { "cultcache.store.v3" } else { STORE_FORMAT_V1 }; for operation in ["open", "soft_push_all", "push_all", "push"] { let temp = tempfile::tempdir()?; let path = temp.path().join("store.msgpack"); - std::fs::write(&path, &bytes)?; + if directory { + std::fs::create_dir(&path)?; + } else { + std::fs::write(&path, &bytes)?; + } let mut store = SingleFileMessagePackBackingStore::new(&path); let outcome = match operation { "open" => store.pull_all().map(|_| ()), @@ -6159,6 +6165,10 @@ mod tests { }; let what = format!("{vector} {operation}"); assert_eq!(outcome.is_ok(), reads, "{what}: {outcome:?}"); + if directory { + assert!(path.is_dir() && std::fs::read_dir(&path)?.next().is_none(), "{what} touched the directory"); + continue; + } if operation == "soft_push_all" && reads { // A soft flush leaves a store it reads byte for byte. assert_eq!(std::fs::read(&path)?, bytes, "{what} rewrote a store it reads"); diff --git a/packages/cultcache-ts/test/cult-cache.test.ts b/packages/cultcache-ts/test/cult-cache.test.ts index 2675545e..2f40358f 100644 --- a/packages/cultcache-ts/test/cult-cache.test.ts +++ b/packages/cultcache-ts/test/cult-cache.test.ts @@ -2582,17 +2582,24 @@ test("SingleFileMessagePackBackingStore replaces a file exactly when it reads", .filter((line) => line.length > 0 && !line.startsWith("#")) .map((line) => line.split(/\s+/u)); // Every vector in the folder has a manifest row. - const listed = rows.map(([vector]) => vector!).filter((vector) => !vector.startsWith("..")).sort(); + const listed = rows.map(([vector]) => vector!).filter((vector) => !vector.startsWith("..") && !vector.startsWith("@")).sort(); assert.deepEqual((await readdir(root)).filter((name) => name.endsWith(".bin")).sort(), listed); const dir = await mkdtemp(join(tmpdir(), "cultcache-readability-")); const seed = await copyVector(dir, join(c2aVectors, "v3-base.msgpack"), "seed.msgpack"); const envelopes = await new SingleFileMessagePackBackingStore(seed).pullAll(); for (const [vector, , , typescript] of rows) { const reads = typescript === "reads"; - const bytes = await readFile(join(root, vector)); + // "@directory": the walker makes an empty directory at the store path; the runtime refuses to open or replace it and leaves it be. + const isDirectory = vector === "@directory"; + const bytes = isDirectory ? Buffer.alloc(0) : await readFile(join(root, vector)); for (const operation of ["pullAll", "softPushAll", "pushAll", "push", "delete"] as const) { const file = join(dir, `${vector.replace(/[^a-z0-9.-]/giu, "_")}.${operation}.msgpack`); - await writeFile(file, bytes); + if (isDirectory) { + assert.equal(typescript, "refuses", "a directory is never a store"); + await mkdir(file); + } else { + await writeFile(file, bytes); + } const store = new SingleFileMessagePackBackingStore(file); const run = () => operation === "pullAll" @@ -2627,6 +2634,10 @@ test("SingleFileMessagePackBackingStore replaces a file exactly when it reads", } else { assert.ok(!keys.includes(envelopes[0]!.key), `${vector} ${operation}`); } + } else if (isDirectory) { + await assert.rejects(run, `${vector} ${operation}`); + assert.ok((await lstat(file)).isDirectory(), `${vector} ${operation} replaced the directory`); + assert.deepEqual(await readdir(file), [], `${vector} ${operation} wrote into the directory`); } else { await assert.rejects( run, diff --git a/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs b/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs index 3ee64e07..55406948 100644 --- a/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs +++ b/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs @@ -7,6 +7,7 @@ using System.Linq; using GameCult.Caching.MessagePack; using MessagePack; using NUnit.Framework; +using NUnit.Framework.Constraints; using static GameCult.Caching.Tests.ElementIdTests; namespace GameCult.Caching.Tests @@ -61,6 +62,39 @@ namespace GameCult.Caching.Tests .Select(line => line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)) .Select(cells => new TestCaseData(cells[0], cells[CSharp] == "reads").SetName($"{cells[0]} {cells[CSharp]}")); + // The row `@directory` has no bytes: the walker makes an empty directory at the store path, and a runtime must refuse it untouched. + private const string DirectoryRow = "@directory"; + + private static byte[] Place(string path, string vector) + { + if (vector != DirectoryRow) + { + var bytes = File.ReadAllBytes(Path.Combine(VectorRoot(), vector)); + File.WriteAllBytes(path, bytes); + return bytes; + } + + if (File.Exists(path)) + File.Delete(path); + Directory.CreateDirectory(path); + return Array.Empty(); + } + + // A directory at the store path is refused as IOException ("not a store file", CultCache.cs): not a store that is unreadable. + private static IResolveConstraint Refusal(string vector) => + vector == DirectoryRow ? Throws.TypeOf() : Throws.TypeOf(); + + private static void AssertUntouched(string path, string vector, byte[] bytes, string message) + { + if (vector == DirectoryRow) + { + Assert.That(Directory.Exists(path), message); + Assert.That(Directory.EnumerateFileSystemEntries(path), Is.Empty, message); + } + else + Assert.That(File.ReadAllBytes(path), Is.EqualTo(bytes), message); + } + private static bool HoldsAVariant(byte[] bytes) => CultDocumentMessagePackSerialization.DeserializeSnapshot(bytes).Records.Any(record => record.Variant != null); @@ -90,14 +124,17 @@ namespace GameCult.Caching.Tests public void OpenReadsExactlyTheFilesTheVectorsSayItReads(string vector, bool reads) { var path = Path.Combine(_directory, "open.cc"); - File.WriteAllBytes(path, File.ReadAllBytes(Path.Combine(VectorRoot(), vector))); + var bytes = Place(path, vector); void Open() => CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }).Dispose(); if (reads) Assert.DoesNotThrow(Open); else - Assert.That(Open, Throws.TypeOf().With.Property(nameof(CultStoreUnreadableException.Path)).EqualTo(path)); + { + Assert.That(Open, vector == DirectoryRow ? Refusal(vector) : Throws.TypeOf().With.Property(nameof(CultStoreUnreadableException.Path)).EqualTo(path)); + AssertUntouched(path, vector, bytes, "a store this runtime cannot open was changed by the attempt"); + } } [TestCaseSource(nameof(Vectors))] @@ -105,8 +142,7 @@ namespace GameCult.Caching.Tests { var path = Seed("flush.cc"); using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }); - var bytes = File.ReadAllBytes(Path.Combine(VectorRoot(), vector)); - File.WriteAllBytes(path, bytes); + var bytes = Place(path, vector); var store = cache.BackingStores[0]; if (reads && HoldsAVariant(bytes)) @@ -126,8 +162,8 @@ namespace GameCult.Caching.Tests return; } - Assert.That(() => store.PushAll(), Throws.TypeOf()); - Assert.That(File.ReadAllBytes(path), Is.EqualTo(bytes), "a file this runtime cannot read was rewritten"); + Assert.That(() => store.PushAll(), Refusal(vector)); + AssertUntouched(path, vector, bytes, "a file this runtime cannot read was rewritten"); } [TestCaseSource(nameof(Vectors))] @@ -135,8 +171,7 @@ namespace GameCult.Caching.Tests { var path = Seed("commit.cc"); using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }); - var bytes = File.ReadAllBytes(Path.Combine(VectorRoot(), vector)); - File.WriteAllBytes(path, bytes); + var bytes = Place(path, vector); if (reads && HoldsAVariant(bytes)) { @@ -153,8 +188,8 @@ namespace GameCult.Caching.Tests return; } - Assert.That(() => cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "e" }, new CultRecordKey("e"))), Throws.TypeOf()); - Assert.That(File.ReadAllBytes(path), Is.EqualTo(bytes), "a file this runtime cannot read was rewritten"); + Assert.That(() => cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "e" }, new CultRecordKey("e"))), Refusal(vector)); + AssertUntouched(path, vector, bytes, "a file this runtime cannot read was rewritten"); } private string DirectoryStore(string name) @@ -679,7 +714,7 @@ namespace GameCult.Caching.Tests [Test] public void EveryVectorInTheFolderHasAManifestRow() { - var rows = Vectors().Select(row => (string)row.Arguments[0]).Where(name => !name.StartsWith("..", StringComparison.Ordinal)).OrderBy(name => name, StringComparer.Ordinal); + var rows = Vectors().Select(row => (string)row.Arguments[0]).Where(name => !name.StartsWith("..", StringComparison.Ordinal) && name != DirectoryRow).OrderBy(name => name, StringComparer.Ordinal); var files = Directory.GetFiles(VectorRoot(), "*.bin").Select(Path.GetFileName).OrderBy(name => name, StringComparer.Ordinal); Assert.That(rows, Is.EqualTo(files)); } diff --git a/tests/GameCult.Mesh.Tests/CultMeshSingleFileReadabilityTests.cs b/tests/GameCult.Mesh.Tests/CultMeshSingleFileReadabilityTests.cs index 556d785d..bf2c0e4c 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshSingleFileReadabilityTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshSingleFileReadabilityTests.cs @@ -16,6 +16,8 @@ namespace GameCult.Mesh.Tests; public sealed class CultMeshSingleFileReadabilityTests { private const int CSharp = 1; + // The row `@directory` has no bytes: the walker makes an empty directory at the store path, and the write must refuse it untouched. + private const string DirectoryRow = "@directory"; private string _root = null!; [SetUp] @@ -67,8 +69,15 @@ public sealed class CultMeshSingleFileReadabilityTests foreach (var (name, write) in writes) { var path = Path.Combine(_root, name.Replace(' ', '-') + ".cc"); - var bytes = File.ReadAllBytes(Path.Combine(VectorRoot(), vector)); - File.WriteAllBytes(path, bytes); + var isDirectory = vector == DirectoryRow; + var bytes = Array.Empty(); + if (isDirectory) + Directory.CreateDirectory(path); + else + { + bytes = File.ReadAllBytes(Path.Combine(VectorRoot(), vector)); + File.WriteAllBytes(path, bytes); + } if (reads) { @@ -96,8 +105,18 @@ public sealed class CultMeshSingleFileReadabilityTests continue; } - Assert.That(() => write(path), Throws.TypeOf(), name); - File.ReadAllBytes(path).Should().Equal(bytes, name + " rewrote a file this runtime cannot read"); + // A directory at the path is refused as IOException ("not a store file"), not as an unreadable store. + if (isDirectory) + Assert.That(() => write(path), Throws.TypeOf(), name); + else + Assert.That(() => write(path), Throws.TypeOf(), name); + if (isDirectory) + { + Directory.Exists(path).Should().BeTrue(name + " replaced a directory"); + Directory.EnumerateFileSystemEntries(path).Should().BeEmpty(name + " wrote into a directory"); + } + else + File.ReadAllBytes(path).Should().Equal(bytes, name + " rewrote a file this runtime cannot read"); } } @@ -115,7 +134,7 @@ public sealed class CultMeshSingleFileReadabilityTests [Test] public void EveryVectorInTheFolderHasAManifestRow() { - var rows = Vectors().Select(row => (string)row.Arguments[0]).Where(name => !name.StartsWith("..", StringComparison.Ordinal)).OrderBy(name => name, StringComparer.Ordinal); + var rows = Vectors().Select(row => (string)row.Arguments[0]).Where(name => !name.StartsWith("..", StringComparison.Ordinal) && name != DirectoryRow).OrderBy(name => name, StringComparer.Ordinal); var files = Directory.GetFiles(VectorRoot(), "*.bin").Select(Path.GetFileName).OrderBy(name => name, StringComparer.Ordinal); rows.Should().Equal(files); } diff --git a/tests/vectors/document-variants-c2a/readability/manifest.txt b/tests/vectors/document-variants-c2a/readability/manifest.txt index 6d7af19d..eda1783a 100644 --- a/tests/vectors/document-variants-c2a/readability/manifest.txt +++ b/tests/vectors/document-variants-c2a/readability/manifest.txt @@ -1,6 +1,7 @@ # Whether each runtime's own reader opens the file. A flush and a commit replace a file exactly when it reads; where a runtime has a soft flush (TypeScript, Rust) it leaves a file that reads byte for byte and refuses one that does not. # A store holding two records under one key is unreadable; keys are ordinal strings (no case folding, no normalisation), so distinct spellings are distinct keys. # Columns: file, csharp, rust, typescript, python. `../v3-base.msgpack` is the one valid store. +# `@directory` is no file: the walker makes an empty directory at the store path and every runtime must refuse to open it and to write over it (flush, commit, delete), leaving the directory empty. ../v3-base.msgpack reads reads reads reads zero-byte.bin refuses refuses refuses refuses empty-array.bin reads reads reads reads @@ -38,3 +39,7 @@ dup-key-different-payload-v3.bin refuses refuses refuses refuses distinct-keys-case-v3.bin reads reads reads reads distinct-keys-nfd-v3.bin reads reads reads reads ../../document-variants-c0/current-catalog-bad-canonical.msgpack refuses refuses refuses refuses +../../document-variants-c0/legacy-catalog-plain.msgpack refuses refuses refuses refuses +../../document-variants-c0/legacy-catalog-extra-slot.msgpack refuses refuses refuses refuses +../../document-variants-c0/legacy-catalog-trailing.msgpack refuses refuses refuses refuses +@directory refuses refuses refuses refuses From af9142539e51fa5c26c6b3f0a8cab03f90a8c55e Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 18:03:41 +0000 Subject: [PATCH 35/93] Fork card: raised_at, required nullable instant outside options_hash (#115) --- .../cultnet/governance/fork-card-vectors.json | 397 ++++++++++++++++-- contracts/cultnet/governance/fork-card.md | 8 +- .../cultnet/governance/fork-card.schema.json | 11 + packages/cultnet-ts/CHANGELOG.md | 1 + .../test/governance-vectors.test.ts | 7 +- scripts/gen-governance-vectors.py | 8 +- .../GovernanceVectorTests.cs | 4 +- 7 files changed, 400 insertions(+), 36 deletions(-) diff --git a/contracts/cultnet/governance/fork-card-vectors.json b/contracts/cultnet/governance/fork-card-vectors.json index 38b255dc..c7c14e43 100644 --- a/contracts/cultnet/governance/fork-card-vectors.json +++ b/contracts/cultnet/governance/fork-card-vectors.json @@ -527,6 +527,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -536,7 +537,7 @@ "order": 3, "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" }, - "messagePackHex": "DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A57469746C65B1536C6F74206D61726B20636F6E73656E74A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657203AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438" + "messagePackHex": "DE0011AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A57469746C65B1536C6F74206D61726B20636F6E73656E74A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657203AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438" }, { "name": "leans_flag_closed_two_deciders", @@ -560,6 +561,7 @@ "leans": "yes", "flag": "provisional: Self expects this to move", "raised_in": null, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a" @@ -572,7 +574,46 @@ "order": 0, "options_hash": "f856edce121db4121b5f1af400b1c471ebbfa6579a042aca9a34ae31c15f5c86" }, - "messagePackHex": "DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A57469746C65B1536C6F74206D61726B20636F6E73656E74A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647390A56C65616E73A3796573A4666C6167D92670726F766973696F6E616C3A2053656C662065787065637473207468697320746F206D6F7665A97261697365645F696EC0A8646563696465727392D92465306431633262332D613439352D343638372D383837392D366135623463336432653166D92439663865376436632D356234612D343339322D383137302D306631653264336334623561A6737461747573A6636C6F736564A672756C696E67AE783A7265736F6C7574696F6E3A79AA72756C65645F66726F6D91A26E31A56F7264657200AC6F7074696F6E735F68617368D94066383536656463653132316462343132316235663161663430306231633437316562626661363537396130343261636139613334616533316331356635633836" + "messagePackHex": "DE0011AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A57469746C65B1536C6F74206D61726B20636F6E73656E74A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647390A56C65616E73A3796573A4666C6167D92670726F766973696F6E616C3A2053656C662065787065637473207468697320746F206D6F7665A97261697365645F696EC0A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727392D92465306431633262332D613439352D343638372D383837392D366135623463336432653166D92439663865376436632D356234612D343339322D383137302D306631653264336334623561A6737461747573A6636C6F736564A672756C696E67AE783A7265736F6C7574696F6E3A79AA72756C65645F66726F6D91A26E31A56F7264657200AC6F7074696F6E735F68617368D94066383536656463653132316462343132316235663161663430306231633437316562626661363537396130343261636139613334616533316331356635633836" + }, + { + "name": "raised_at_nil", + "message": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "title": "Slot mark consent", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": null, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "messagePackHex": "DE0011AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A57469746C65B1536C6F74206D61726B20636F6E73656E74A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174C0A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657203AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438" }, { "name": "depends_at_both_caps", @@ -608,6 +649,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -617,7 +659,7 @@ "order": 3, "options_hash": "52163c7ae8a2c15d4380e7854505bedb5b3c84976ed301d86afc99a7bd0e03c8" }, - "messagePackHex": "DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A57469746C65B1536C6F74206D61726B20636F6E73656E74A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647398A161A161A161A161A161A161A161DA03E8C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657203AC6F7074696F6E735F68617368D94035323136336337616538613263313564343338306537383534353035626564623562336338343937366564333031643836616663393961376264306530336338" + "messagePackHex": "DE0011AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A57469746C65B1536C6F74206D61726B20636F6E73656E74A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647398A161A161A161A161A161A161A161DA03E8C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657203AC6F7074696F6E735F68617368D94035323136336337616538613263313564343338306537383534353035626564623562336338343937366564333031643836616663393961376264306530336338" }, { "name": "closed_without_a_ruling_the_question_was_withdrawn", @@ -646,6 +688,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -655,7 +698,7 @@ "order": 3, "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" }, - "messagePackHex": "DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A57469746C65B1536C6F74206D61726B20636F6E73656E74A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A6636C6F736564A672756C696E67C0AA72756C65645F66726F6D90A56F7264657203AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438" + "messagePackHex": "DE0011AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A57469746C65B1536C6F74206D61726B20636F6E73656E74A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A6636C6F736564A672756C696E67C0AA72756C65645F66726F6D90A56F7264657203AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438" }, { "name": "deciders_at_the_cap_of_64", @@ -684,6 +727,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "00000001-0000-4000-8000-000000000001", "00000002-0000-4000-8000-000000000002", @@ -756,7 +800,7 @@ "order": 3, "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" }, - "messagePackHex": "DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A57469746C65B1536C6F74206D61726B20636F6E73656E74A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A86465636964657273DC0040D92430303030303030312D303030302D343030302D383030302D303030303030303030303031D92430303030303030322D303030302D343030302D383030302D303030303030303030303032D92430303030303030332D303030302D343030302D383030302D303030303030303030303033D92430303030303030342D303030302D343030302D383030302D303030303030303030303034D92430303030303030352D303030302D343030302D383030302D303030303030303030303035D92430303030303030362D303030302D343030302D383030302D303030303030303030303036D92430303030303030372D303030302D343030302D383030302D303030303030303030303037D92430303030303030382D303030302D343030302D383030302D303030303030303030303038D92430303030303030392D303030302D343030302D383030302D303030303030303030303039D92430303030303030612D303030302D343030302D383030302D303030303030303030303061D92430303030303030622D303030302D343030302D383030302D303030303030303030303062D92430303030303030632D303030302D343030302D383030302D303030303030303030303063D92430303030303030642D303030302D343030302D383030302D303030303030303030303064D92430303030303030652D303030302D343030302D383030302D303030303030303030303065D92430303030303030662D303030302D343030302D383030302D303030303030303030303066D92430303030303031302D303030302D343030302D383030302D303030303030303030303130D92430303030303031312D303030302D343030302D383030302D303030303030303030303131D92430303030303031322D303030302D343030302D383030302D303030303030303030303132D92430303030303031332D303030302D343030302D383030302D303030303030303030303133D92430303030303031342D303030302D343030302D383030302D303030303030303030303134D92430303030303031352D303030302D343030302D383030302D303030303030303030303135D92430303030303031362D303030302D343030302D383030302D303030303030303030303136D92430303030303031372D303030302D343030302D383030302D303030303030303030303137D92430303030303031382D303030302D343030302D383030302D303030303030303030303138D92430303030303031392D303030302D343030302D383030302D303030303030303030303139D92430303030303031612D303030302D343030302D383030302D303030303030303030303161D92430303030303031622D303030302D343030302D383030302D303030303030303030303162D92430303030303031632D303030302D343030302D383030302D303030303030303030303163D92430303030303031642D303030302D343030302D383030302D303030303030303030303164D92430303030303031652D303030302D343030302D383030302D303030303030303030303165D92430303030303031662D303030302D343030302D383030302D303030303030303030303166D92430303030303032302D303030302D343030302D383030302D303030303030303030303230D92430303030303032312D303030302D343030302D383030302D303030303030303030303231D92430303030303032322D303030302D343030302D383030302D303030303030303030303232D92430303030303032332D303030302D343030302D383030302D303030303030303030303233D92430303030303032342D303030302D343030302D383030302D303030303030303030303234D92430303030303032352D303030302D343030302D383030302D303030303030303030303235D92430303030303032362D303030302D343030302D383030302D303030303030303030303236D92430303030303032372D303030302D343030302D383030302D303030303030303030303237D92430303030303032382D303030302D343030302D383030302D303030303030303030303238D92430303030303032392D303030302D343030302D383030302D303030303030303030303239D92430303030303032612D303030302D343030302D383030302D303030303030303030303261D92430303030303032622D303030302D343030302D383030302D303030303030303030303262D92430303030303032632D303030302D343030302D383030302D303030303030303030303263D92430303030303032642D303030302D343030302D383030302D303030303030303030303264D92430303030303032652D303030302D343030302D383030302D303030303030303030303265D92430303030303032662D303030302D343030302D383030302D303030303030303030303266D92430303030303033302D303030302D343030302D383030302D303030303030303030303330D92430303030303033312D303030302D343030302D383030302D303030303030303030303331D92430303030303033322D303030302D343030302D383030302D303030303030303030303332D92430303030303033332D303030302D343030302D383030302D303030303030303030303333D92430303030303033342D303030302D343030302D383030302D303030303030303030303334D92430303030303033352D303030302D343030302D383030302D303030303030303030303335D92430303030303033362D303030302D343030302D383030302D303030303030303030303336D92430303030303033372D303030302D343030302D383030302D303030303030303030303337D92430303030303033382D303030302D343030302D383030302D303030303030303030303338D92430303030303033392D303030302D343030302D383030302D303030303030303030303339D92430303030303033612D303030302D343030302D383030302D303030303030303030303361D92430303030303033622D303030302D343030302D383030302D303030303030303030303362D92430303030303033632D303030302D343030302D383030302D303030303030303030303363D92430303030303033642D303030302D343030302D383030302D303030303030303030303364D92430303030303033652D303030302D343030302D383030302D303030303030303030303365D92430303030303033662D303030302D343030302D383030302D303030303030303030303366D92430303030303034302D303030302D343030302D383030302D303030303030303030303430A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657203AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438" + "messagePackHex": "DE0011AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A57469746C65B1536C6F74206D61726B20636F6E73656E74A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA86465636964657273DC0040D92430303030303030312D303030302D343030302D383030302D303030303030303030303031D92430303030303030322D303030302D343030302D383030302D303030303030303030303032D92430303030303030332D303030302D343030302D383030302D303030303030303030303033D92430303030303030342D303030302D343030302D383030302D303030303030303030303034D92430303030303030352D303030302D343030302D383030302D303030303030303030303035D92430303030303030362D303030302D343030302D383030302D303030303030303030303036D92430303030303030372D303030302D343030302D383030302D303030303030303030303037D92430303030303030382D303030302D343030302D383030302D303030303030303030303038D92430303030303030392D303030302D343030302D383030302D303030303030303030303039D92430303030303030612D303030302D343030302D383030302D303030303030303030303061D92430303030303030622D303030302D343030302D383030302D303030303030303030303062D92430303030303030632D303030302D343030302D383030302D303030303030303030303063D92430303030303030642D303030302D343030302D383030302D303030303030303030303064D92430303030303030652D303030302D343030302D383030302D303030303030303030303065D92430303030303030662D303030302D343030302D383030302D303030303030303030303066D92430303030303031302D303030302D343030302D383030302D303030303030303030303130D92430303030303031312D303030302D343030302D383030302D303030303030303030303131D92430303030303031322D303030302D343030302D383030302D303030303030303030303132D92430303030303031332D303030302D343030302D383030302D303030303030303030303133D92430303030303031342D303030302D343030302D383030302D303030303030303030303134D92430303030303031352D303030302D343030302D383030302D303030303030303030303135D92430303030303031362D303030302D343030302D383030302D303030303030303030303136D92430303030303031372D303030302D343030302D383030302D303030303030303030303137D92430303030303031382D303030302D343030302D383030302D303030303030303030303138D92430303030303031392D303030302D343030302D383030302D303030303030303030303139D92430303030303031612D303030302D343030302D383030302D303030303030303030303161D92430303030303031622D303030302D343030302D383030302D303030303030303030303162D92430303030303031632D303030302D343030302D383030302D303030303030303030303163D92430303030303031642D303030302D343030302D383030302D303030303030303030303164D92430303030303031652D303030302D343030302D383030302D303030303030303030303165D92430303030303031662D303030302D343030302D383030302D303030303030303030303166D92430303030303032302D303030302D343030302D383030302D303030303030303030303230D92430303030303032312D303030302D343030302D383030302D303030303030303030303231D92430303030303032322D303030302D343030302D383030302D303030303030303030303232D92430303030303032332D303030302D343030302D383030302D303030303030303030303233D92430303030303032342D303030302D343030302D383030302D303030303030303030303234D92430303030303032352D303030302D343030302D383030302D303030303030303030303235D92430303030303032362D303030302D343030302D383030302D303030303030303030303236D92430303030303032372D303030302D343030302D383030302D303030303030303030303237D92430303030303032382D303030302D343030302D383030302D303030303030303030303238D92430303030303032392D303030302D343030302D383030302D303030303030303030303239D92430303030303032612D303030302D343030302D383030302D303030303030303030303261D92430303030303032622D303030302D343030302D383030302D303030303030303030303262D92430303030303032632D303030302D343030302D383030302D303030303030303030303263D92430303030303032642D303030302D343030302D383030302D303030303030303030303264D92430303030303032652D303030302D343030302D383030302D303030303030303030303265D92430303030303032662D303030302D343030302D383030302D303030303030303030303266D92430303030303033302D303030302D343030302D383030302D303030303030303030303330D92430303030303033312D303030302D343030302D383030302D303030303030303030303331D92430303030303033322D303030302D343030302D383030302D303030303030303030303332D92430303030303033332D303030302D343030302D383030302D303030303030303030303333D92430303030303033342D303030302D343030302D383030302D303030303030303030303334D92430303030303033352D303030302D343030302D383030302D303030303030303030303335D92430303030303033362D303030302D343030302D383030302D303030303030303030303336D92430303030303033372D303030302D343030302D383030302D303030303030303030303337D92430303030303033382D303030302D343030302D383030302D303030303030303030303338D92430303030303033392D303030302D343030302D383030302D303030303030303030303339D92430303030303033612D303030302D343030302D383030302D303030303030303030303361D92430303030303033622D303030302D343030302D383030302D303030303030303030303362D92430303030303033632D303030302D343030302D383030302D303030303030303030303363D92430303030303033642D303030302D343030302D383030302D303030303030303030303364D92430303030303033652D303030302D343030302D383030302D303030303030303030303365D92430303030303033662D303030302D343030302D383030302D303030303030303030303366D92430303030303034302D303030302D343030302D383030302D303030303030303030303430A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657203AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438" } ], "invalid": [ @@ -796,6 +840,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -834,6 +879,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -872,6 +918,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -910,6 +957,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -944,6 +992,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -982,6 +1031,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [], "status": "open", "ruling": null, @@ -1018,6 +1068,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -1056,6 +1107,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -1096,6 +1148,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -1134,6 +1187,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -1172,6 +1226,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -1210,6 +1265,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -1248,6 +1304,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -1286,6 +1343,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -1324,6 +1382,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" @@ -1363,6 +1422,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -1403,6 +1463,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -1441,6 +1502,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -1481,6 +1543,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -1491,6 +1554,122 @@ "options_hash": "14b8ffbdb61106dbc06f69f3fc8e2072af7a205d766e4cc040e89733013d54dc" } }, + { + "name": "raised_at_epoch_integer", + "why": "raised_at is an RFC3339 instant string or nil, not epoch milliseconds", + "message": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "title": "Slot mark consent", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": 1762767000000, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + } + }, + { + "name": "raised_at_date_only", + "why": "raised_at is an instant with a time and a Z, not a date", + "message": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "title": "Slot mark consent", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "raised_at": "2026-11-10", + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + } + }, + { + "name": "raised_at_missing", + "why": "map-mode writes every key: raised_at is required, nil when unknown", + "message": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "title": "Slot mark consent", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "leans": null, + "flag": null, + "raised_in": { + "kind": "CutSpec", + "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" + }, + "deciders": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + } + }, { "name": "deciders_65", "why": "deciders holds at most 64 (Huginn's bound)", @@ -1519,6 +1698,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "00000001-0000-4000-8000-000000000001", "00000002-0000-4000-8000-000000000002", @@ -2448,6 +2628,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -2459,7 +2640,7 @@ }, "counted_answer_id": null }, - "messagePackHex": "84AD736368656D6156657273696F6EBE67616D6563756C742E666F726B2E616E737765725F726573756C742E7630A6616E73776572DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AA7374616C655F666F726BAC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A57469746C65B1536C6F74206D61726B20636F6E73656E74A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657203AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0" + "messagePackHex": "84AD736368656D6156657273696F6EBE67616D6563756C742E666F726B2E616E737765725F726573756C742E7630A6616E73776572DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AA7374616C655F666F726BAC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031A463617264DE0011AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A57469746C65B1536C6F74206D61726B20636F6E73656E74A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657203AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0" }, { "name": "not_a_decider", @@ -3146,6 +3327,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -3163,7 +3345,7 @@ "answers": [] } }, - "messagePackHex": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134A57469746C65A6466F726B2034A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657204AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161B1636F756E7465645F616E737765725F6964C0A7616E737765727390" + "messagePackHex": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0011AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134A57469746C65A6466F726B2034A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657204AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161B1636F756E7465645F616E737765725F6964C0A7616E737765727390" }, { "name": "open_counted", @@ -3194,6 +3376,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -3230,7 +3413,7 @@ ] } }, - "messagePackHex": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D303030303030303030303230" + "messagePackHex": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0011AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D303030303030303030303230" }, { "name": "closed_with_ruling_and_a_refusal", @@ -3261,6 +3444,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -3317,7 +3501,7 @@ ] } }, - "messagePackHex": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A57469746C65A6466F726B2033A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A6636C6F736564A672756C696E67D92663756C746C69622D676170733A7265736F6C7574696F6E3A7175657374696F6E2E71332E6E31AA72756C65645F66726F6D91A26E31A56F7264657203AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532B1636F756E7465645F616E737765725F6964A26E31A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A3796573A46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032312D303030302D343030302D383030302D303030303030303030303231DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AB666F726B5F636C6F736564AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30343A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92439663865376436632D356234612D343339322D383137302D306631653264336334623561A9617574686F72697479A753746577617264AA726571756573745F6964D92430303030303032322D303030302D343030302D383030302D303030303030303030303232" + "messagePackHex": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0011AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A57469746C65A6466F726B2033A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A6636C6F736564A672756C696E67D92663756C746C69622D676170733A7265736F6C7574696F6E3A7175657374696F6E2E71332E6E31AA72756C65645F66726F6D91A26E31A56F7264657203AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532B1636F756E7465645F616E737765725F6964A26E31A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A3796573A46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032312D303030302D343030302D383030302D303030303030303030303231DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AB666F726B5F636C6F736564AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30343A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92439663865376436632D356234612D343339322D383137302D306631653264336334623561A9617574686F72697479A753746577617264AA726571756573745F6964D92430303030303032322D303030302D343030302D383030302D303030303030303030303232" } ], "invalid": [ @@ -3351,6 +3535,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -3361,7 +3546,7 @@ "options_hash": "16d20a6dc44b6cc00afd378e9f0c8b51dfe5b07e869e1a2aa9588de1de587fab" } }, - "messagePackHex": "82AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162" + "messagePackHex": "82AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0011AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162" }, { "name": "record_of_another_fork", @@ -3393,6 +3578,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -3429,7 +3615,7 @@ ] } }, - "messagePackHex": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7139AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D303030303030303030303230" + "messagePackHex": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0011AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7139AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D303030303030303030303230" }, { "name": "record_under_another_hash", @@ -3461,6 +3647,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -3497,7 +3684,7 @@ ] } }, - "messagePackHex": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031346238666662646236313130366462633036663639663366633865323037326166376132303564373636653463633034306538393733333031336435346463B1636F756E7465645F616E737765725F6964A26E31A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D303030303030303030303230" + "messagePackHex": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0011AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031346238666662646236313130366462633036663639663366633865323037326166376132303564373636653463633034306538393733333031336435346463B1636F756E7465645F616E737765725F6964A26E31A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D303030303030303030303230" }, { "name": "counted_answer_not_derived", @@ -3529,6 +3716,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -3565,7 +3753,7 @@ ] } }, - "messagePackHex": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964C0A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D303030303030303030303230" + "messagePackHex": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0011AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964C0A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D303030303030303030303230" }, { "name": "card_hash_wrong", @@ -3597,6 +3785,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -3614,7 +3803,7 @@ "answers": [] } }, - "messagePackHex": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134A57469746C65A6466F726B2034A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657204AC6F7074696F6E735F68617368D94031346238666662646236313130366462633036663639663366633865323037326166376132303564373636653463633034306538393733333031336435346463A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134AC6F7074696F6E735F68617368D94031346238666662646236313130366462633036663639663366633865323037326166376132303564373636653463633034306538393733333031336435346463B1636F756E7465645F616E737765725F6964C0A7616E737765727390" + "messagePackHex": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0011AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134A57469746C65A6466F726B2034A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657204AC6F7074696F6E735F68617368D94031346238666662646236313130366462633036663639663366633865323037326166376132303564373636653463633034306538393733333031336435346463A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134AC6F7074696F6E735F68617368D94031346238666662646236313130366462633036663639663366633865323037326166376132303564373636653463633034306538393733333031336435346463B1636F756E7465645F616E737765725F6964C0A7616E737765727390" } ] }, @@ -3656,6 +3845,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -3719,6 +3909,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -3739,7 +3930,7 @@ ], "next_cursor": "more" }, - "messagePackHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A473343132AA6F70656E5F636F756E7403AE7265636F726465645F636F756E7402A5666F726B739283AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D30303030303030303030323083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134A57469746C65A6466F726B2034A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657204AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72A46D6F7265" + "messagePackHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A473343132AA6F70656E5F636F756E7403AE7265636F726465645F636F756E7402A5666F726B739283AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0011AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D30303030303030303030323083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0011AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134A57469746C65A6466F726B2034A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657204AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72A46D6F7265" }, { "name": "stamp_malformed", @@ -3778,6 +3969,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -3841,6 +4033,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -3861,7 +4054,7 @@ ], "next_cursor": "c4" }, - "messagePackHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A3343132AA6F70656E5F636F756E7403AE7265636F726465645F636F756E7402A5666F726B739283AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D30303030303030303030323083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134A57469746C65A6466F726B2034A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657204AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72A26334" + "messagePackHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A3343132AA6F70656E5F636F756E7403AE7265636F726465645F636F756E7402A5666F726B739283AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0011AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D30303030303030303030323083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0011AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134A57469746C65A6466F726B2034A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657204AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72A26334" }, { "name": "count_negative", @@ -3900,6 +4093,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -3963,6 +4157,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -3983,7 +4178,7 @@ ], "next_cursor": "c4" }, - "messagePackHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A473343132AA6F70656E5F636F756E74FFAE7265636F726465645F636F756E7402A5666F726B739283AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D30303030303030303030323083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134A57469746C65A6466F726B2034A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657204AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72A26334" + "messagePackHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A473343132AA6F70656E5F636F756E74FFAE7265636F726465645F636F756E7402A5666F726B739283AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0011AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D30303030303030303030323083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0011AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134A57469746C65A6466F726B2034A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657204AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72A26334" }, { "name": "two_cards_share_one_order", @@ -4022,6 +4217,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -4085,6 +4281,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -4129,6 +4326,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -4173,6 +4371,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -4256,6 +4455,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -4300,6 +4500,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -4320,7 +4521,7 @@ ], "next_cursor": null }, - "messagePackHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A473343132AA6F70656E5F636F756E7404AE7265636F726465645F636F756E7402A5666F726B739683AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D30303030303030303030323083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134A57469746C65A6466F726B2034A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657204AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161B1636F756E7465645F616E737765725F6964C0A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964BA67616D6563756C742D636861743A7175657374696F6E3A713462A57469746C65A6466F726B2034A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657204AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964BA67616D6563756C742D636861743A7175657374696F6E3A713462AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161B1636F756E7465645F616E737765725F6964C0A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A57469746C65A6466F726B2033A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A6636C6F736564A672756C696E67D92663756C746C69622D676170733A7265736F6C7574696F6E3A7175657374696F6E2E71332E6E31AA72756C65645F66726F6D91A26E31A56F7264657203AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532B1636F756E7465645F616E737765725F6964A26E31A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A3796573A46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032312D303030302D343030302D383030302D303030303030303030303231DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AB666F726B5F636C6F736564AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30343A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92439663865376436632D356234612D343339322D383137302D306631653264336334623561A9617574686F72697479A753746577617264AA726571756573745F6964D92430303030303032322D303030302D343030302D383030302D30303030303030303030323283AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132A57469746C65A6466F726B2032A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657202AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739B1636F756E7465645F616E737765725F6964C0A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131A57469746C65A6466F726B2031A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A6636C6F736564A672756C696E67D92663756C746C69622D676170733A7265736F6C7574696F6E3A7175657374696F6E2E71312E6E31AA72756C65645F66726F6D90A56F7264657201AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72C0", + "messagePackHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A473343132AA6F70656E5F636F756E7404AE7265636F726465645F636F756E7402A5666F726B739683AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0011AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D30303030303030303030323083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0011AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134A57469746C65A6466F726B2034A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657204AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161B1636F756E7465645F616E737765725F6964C0A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0011AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964BA67616D6563756C742D636861743A7175657374696F6E3A713462A57469746C65A6466F726B2034A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657204AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964BA67616D6563756C742D636861743A7175657374696F6E3A713462AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161B1636F756E7465645F616E737765725F6964C0A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0011AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A57469746C65A6466F726B2033A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A6636C6F736564A672756C696E67D92663756C746C69622D676170733A7265736F6C7574696F6E3A7175657374696F6E2E71332E6E31AA72756C65645F66726F6D91A26E31A56F7264657203AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532B1636F756E7465645F616E737765725F6964A26E31A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A3796573A46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032312D303030302D343030302D383030302D303030303030303030303231DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AB666F726B5F636C6F736564AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30343A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92439663865376436632D356234612D343339322D383137302D306631653264336334623561A9617574686F72697479A753746577617264AA726571756573745F6964D92430303030303032322D303030302D343030302D383030302D30303030303030303030323283AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0011AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132A57469746C65A6466F726B2032A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657202AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739B1636F756E7465645F616E737765725F6964C0A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0011AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131A57469746C65A6466F726B2031A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A6636C6F736564A672756C696E67D92663756C746C69622D676170733A7265736F6C7574696F6E3A7175657374696F6E2E71312E6E31AA72756C65645F66726F6D90A56F7264657201AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72C0", "refusal": "order 4 is shared by gamecult-chat:question:q4 and gamecult-chat:question:q4b" } ], @@ -4356,6 +4557,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -4419,6 +4621,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -4463,6 +4666,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -4546,6 +4750,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -4590,6 +4795,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -4651,6 +4857,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -4714,6 +4921,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -4734,7 +4942,7 @@ ], "next_cursor": "c4" }, - "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A473343132AA6F70656E5F636F756E7403AE7265636F726465645F636F756E7402A5666F726B739283AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D30303030303030303030323083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134A57469746C65A6466F726B2034A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657204AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72A26334" + "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A473343132AA6F70656E5F636F756E7403AE7265636F726465645F636F756E7402A5666F726B739283AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0011AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D30303030303030303030323083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0011AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134A57469746C65A6466F726B2034A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657204AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72A26334" }, { "request": { @@ -4778,6 +4986,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -4798,7 +5007,7 @@ ], "next_cursor": null }, - "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A473343132AA6F70656E5F636F756E7403AE7265636F726465645F636F756E7402A5666F726B739183AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132A57469746C65A6466F726B2032A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657202AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72C0" + "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A473343132AA6F70656E5F636F756E7403AE7265636F726465645F636F756E7402A5666F726B739183AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0011AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132A57469746C65A6466F726B2032A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657202AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72C0" } ] }, @@ -4833,6 +5042,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -4896,6 +5106,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -4940,6 +5151,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -5023,6 +5235,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -5067,6 +5280,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -5128,6 +5342,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -5191,6 +5406,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -5235,6 +5451,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -5318,6 +5535,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -5362,6 +5580,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -5382,7 +5601,7 @@ ], "next_cursor": null }, - "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A473343132AA6F70656E5F636F756E7403AE7265636F726465645F636F756E7402A5666F726B739583AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D30303030303030303030323083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134A57469746C65A6466F726B2034A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657204AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161B1636F756E7465645F616E737765725F6964C0A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A57469746C65A6466F726B2033A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A6636C6F736564A672756C696E67D92663756C746C69622D676170733A7265736F6C7574696F6E3A7175657374696F6E2E71332E6E31AA72756C65645F66726F6D91A26E31A56F7264657203AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532B1636F756E7465645F616E737765725F6964A26E31A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A3796573A46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032312D303030302D343030302D383030302D303030303030303030303231DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AB666F726B5F636C6F736564AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30343A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92439663865376436632D356234612D343339322D383137302D306631653264336334623561A9617574686F72697479A753746577617264AA726571756573745F6964D92430303030303032322D303030302D343030302D383030302D30303030303030303030323283AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132A57469746C65A6466F726B2032A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657202AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739B1636F756E7465645F616E737765725F6964C0A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131A57469746C65A6466F726B2031A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A6636C6F736564A672756C696E67D92663756C746C69622D676170733A7265736F6C7574696F6E3A7175657374696F6E2E71312E6E31AA72756C65645F66726F6D90A56F7264657201AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72C0" + "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A473343132AA6F70656E5F636F756E7403AE7265636F726465645F636F756E7402A5666F726B739583AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0011AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D30303030303030303030323083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0011AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134A57469746C65A6466F726B2034A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657204AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161B1636F756E7465645F616E737765725F6964C0A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0011AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A57469746C65A6466F726B2033A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A6636C6F736564A672756C696E67D92663756C746C69622D676170733A7265736F6C7574696F6E3A7175657374696F6E2E71332E6E31AA72756C65645F66726F6D91A26E31A56F7264657203AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532B1636F756E7465645F616E737765725F6964A26E31A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A3796573A46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032312D303030302D343030302D383030302D303030303030303030303231DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AB666F726B5F636C6F736564AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30343A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92439663865376436632D356234612D343339322D383137302D306631653264336334623561A9617574686F72697479A753746577617264AA726571756573745F6964D92430303030303032322D303030302D343030302D383030302D30303030303030303030323283AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0011AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132A57469746C65A6466F726B2032A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657202AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739B1636F756E7465645F616E737765725F6964C0A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0011AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131A57469746C65A6466F726B2031A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A6636C6F736564A672756C696E67D92663756C746C69622D676170733A7265736F6C7574696F6E3A7175657374696F6E2E71312E6E31AA72756C65645F66726F6D90A56F7264657201AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72C0" } ] }, @@ -5417,6 +5636,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -5480,6 +5700,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -5524,6 +5745,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -5607,6 +5829,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -5651,6 +5874,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -5712,6 +5936,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -5771,7 +5996,7 @@ ], "next_cursor": "c3" }, - "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A473343132AA6F70656E5F636F756E7403AE7265636F726465645F636F756E7402A5666F726B739183AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A57469746C65A6466F726B2033A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A6636C6F736564A672756C696E67D92663756C746C69622D676170733A7265736F6C7574696F6E3A7175657374696F6E2E71332E6E31AA72756C65645F66726F6D91A26E31A56F7264657203AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532B1636F756E7465645F616E737765725F6964A26E31A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A3796573A46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032312D303030302D343030302D383030302D303030303030303030303231DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AB666F726B5F636C6F736564AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30343A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92439663865376436632D356234612D343339322D383137302D306631653264336334623561A9617574686F72697479A753746577617264AA726571756573745F6964D92430303030303032322D303030302D343030302D383030302D303030303030303030303232AB6E6578745F637572736F72A26333" + "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A473343132AA6F70656E5F636F756E7403AE7265636F726465645F636F756E7402A5666F726B739183AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0011AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A57469746C65A6466F726B2033A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A6636C6F736564A672756C696E67D92663756C746C69622D676170733A7265736F6C7574696F6E3A7175657374696F6E2E71332E6E31AA72756C65645F66726F6D91A26E31A56F7264657203AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532B1636F756E7465645F616E737765725F6964A26E31A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A3796573A46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032312D303030302D343030302D383030302D303030303030303030303231DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AB666F726B5F636C6F736564AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30343A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92439663865376436632D356234612D343339322D383137302D306631653264336334623561A9617574686F72697479A753746577617264AA726571756573745F6964D92430303030303032322D303030302D343030302D383030302D303030303030303030303232AB6E6578745F637572736F72A26333" }, { "request": { @@ -5815,6 +6040,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -5835,7 +6061,7 @@ ], "next_cursor": null }, - "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A473343132AA6F70656E5F636F756E7403AE7265636F726465645F636F756E7402A5666F726B739183AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131A57469746C65A6466F726B2031A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A6636C6F736564A672756C696E67D92663756C746C69622D676170733A7265736F6C7574696F6E3A7175657374696F6E2E71312E6E31AA72756C65645F66726F6D90A56F7264657201AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72C0" + "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A473343132AA6F70656E5F636F756E7403AE7265636F726465645F636F756E7402A5666F726B739183AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0011AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131A57469746C65A6466F726B2031A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A6636C6F736564A672756C696E67D92663756C746C69622D676170733A7265736F6C7574696F6E3A7175657374696F6E2E71312E6E31AA72756C65645F66726F6D90A56F7264657201AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72C0" } ] }, @@ -5870,6 +6096,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -5933,6 +6160,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -5977,6 +6205,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -6060,6 +6289,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -6104,6 +6334,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -6175,6 +6406,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -6219,6 +6451,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -6263,6 +6496,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -6307,6 +6541,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -6368,6 +6603,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -6412,6 +6648,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -6456,6 +6693,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -6476,7 +6714,7 @@ ], "next_cursor": "c1" }, - "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A27339AA6F70656E5F636F756E7404AE7265636F726465645F636F756E7400A5666F726B739383AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A57469746C65A6466F726B2033A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657203AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532B1636F756E7465645F616E737765725F6964C0A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132A57469746C65A6466F726B2032A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657202AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739B1636F756E7465645F616E737765725F6964C0A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131A57469746C65A6466F726B2031A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657201AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72A26331" + "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A27339AA6F70656E5F636F756E7404AE7265636F726465645F636F756E7400A5666F726B739383AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0011AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A57469746C65A6466F726B2033A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657203AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532B1636F756E7465645F616E737765725F6964C0A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0011AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132A57469746C65A6466F726B2032A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657202AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739B1636F756E7465645F616E737765725F6964C0A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0011AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131A57469746C65A6466F726B2031A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657201AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72A26331" }, { "request": { @@ -6520,6 +6758,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -6540,7 +6779,7 @@ ], "next_cursor": null }, - "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A27339AA6F70656E5F636F756E7404AE7265636F726465645F636F756E7400A5666F726B739183AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7130A57469746C65A6466F726B2030A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657200AC6F7074696F6E735F68617368D94031373737626430366666616664663665383162656435393739663337313933633462346537633364323530346639303465393133313031326630373637613964A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7130AC6F7074696F6E735F68617368D94031373737626430366666616664663665383162656435393739663337313933633462346537633364323530346639303465393133313031326630373637613964B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72C0" + "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A27339AA6F70656E5F636F756E7404AE7265636F726465645F636F756E7400A5666F726B739183AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0011AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7130A57469746C65A6466F726B2030A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657200AC6F7074696F6E735F68617368D94031373737626430366666616664663665383162656435393739663337313933633462346537633364323530346639303465393133313031326630373637613964A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7130AC6F7074696F6E735F68617368D94031373737626430366666616664663665383162656435393739663337313933633462346537633364323530346639303465393133313031326630373637613964B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72C0" } ] }, @@ -6603,6 +6842,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -6647,6 +6887,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -6691,6 +6932,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -6735,6 +6977,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -6796,6 +7039,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -6840,6 +7084,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -6860,7 +7105,7 @@ ], "next_cursor": "c2" }, - "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A27339AA6F70656E5F636F756E7404AE7265636F726465645F636F756E7400A5666F726B739283AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A57469746C65A6466F726B2033A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657203AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532B1636F756E7465645F616E737765725F6964C0A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132A57469746C65A6466F726B2032A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657202AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72A26332" + "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A27339AA6F70656E5F636F756E7404AE7265636F726465645F636F756E7400A5666F726B739283AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0011AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A57469746C65A6466F726B2033A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657203AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532B1636F756E7465645F616E737765725F6964C0A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0011AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132A57469746C65A6466F726B2032A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657202AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72A26332" }, { "request": { @@ -6904,6 +7149,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -6948,6 +7194,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -6968,7 +7215,7 @@ ], "next_cursor": null }, - "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A27339AA6F70656E5F636F756E7404AE7265636F726465645F636F756E7400A5666F726B739283AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131A57469746C65A6466F726B2031A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657201AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361B1636F756E7465645F616E737765725F6964C0A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7130A57469746C65A6466F726B2030A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657200AC6F7074696F6E735F68617368D94031373737626430366666616664663665383162656435393739663337313933633462346537633364323530346639303465393133313031326630373637613964A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7130AC6F7074696F6E735F68617368D94031373737626430366666616664663665383162656435393739663337313933633462346537633364323530346639303465393133313031326630373637613964B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72C0" + "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A27339AA6F70656E5F636F756E7404AE7265636F726465645F636F756E7400A5666F726B739283AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0011AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131A57469746C65A6466F726B2031A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657201AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361B1636F756E7465645F616E737765725F6964C0A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0011AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7130A57469746C65A6466F726B2030A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657200AC6F7074696F6E735F68617368D94031373737626430366666616664663665383162656435393739663337313933633462346537633364323530346639303465393133313031326630373637613964A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7130AC6F7074696F6E735F68617368D94031373737626430366666616664663665383162656435393739663337313933633462346537633364323530346639303465393133313031326630373637613964B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72C0" } ] }, @@ -7003,6 +7250,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -7047,6 +7295,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -7091,6 +7340,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -7135,6 +7385,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -7214,6 +7465,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -7258,6 +7510,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -7302,6 +7555,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -7322,7 +7576,7 @@ ], "next_cursor": null }, - "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A27339AA6F70656E5F636F756E7404AE7265636F726465645F636F756E7400A5666F726B739383AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132A57469746C65A6466F726B2032A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657202AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739B1636F756E7465645F616E737765725F6964C0A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131A57469746C65A6466F726B2031A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657201AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361B1636F756E7465645F616E737765725F6964C0A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7130A57469746C65A6466F726B2030A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657200AC6F7074696F6E735F68617368D94031373737626430366666616664663665383162656435393739663337313933633462346537633364323530346639303465393133313031326630373637613964A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7130AC6F7074696F6E735F68617368D94031373737626430366666616664663665383162656435393739663337313933633462346537633364323530346639303465393133313031326630373637613964B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72C0" + "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A27339AA6F70656E5F636F756E7404AE7265636F726465645F636F756E7400A5666F726B739383AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0011AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132A57469746C65A6466F726B2032A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657202AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739B1636F756E7465645F616E737765725F6964C0A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0011AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131A57469746C65A6466F726B2031A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657201AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361B1636F756E7465645F616E737765725F6964C0A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0011AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7130A57469746C65A6466F726B2030A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657200AC6F7074696F6E735F68617368D94031373737626430366666616664663665383162656435393739663337313933633462346537633364323530346639303465393133313031326630373637613964A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7130AC6F7074696F6E735F68617368D94031373737626430366666616664663665383162656435393739663337313933633462346537633364323530346639303465393133313031326630373637613964B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72C0" } ] }, @@ -7357,6 +7611,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -7401,6 +7656,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -7462,6 +7718,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -7482,7 +7739,7 @@ ], "next_cursor": "c1" }, - "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A27339AA6F70656E5F636F756E7402AE7265636F726465645F636F756E7400A5666F726B739183AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0010AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131A57469746C65A6466F726B2031A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657201AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72A26331" + "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A27339AA6F70656E5F636F756E7402AE7265636F726465645F636F756E7400A5666F726B739183AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0011AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131A57469746C65A6466F726B2031A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657201AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361A67265636F726485AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361B1636F756E7465645F616E737765725F6964C0A7616E737765727390AB6E6578745F637572736F72A26331" }, { "request": { @@ -7537,6 +7794,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -7581,6 +7839,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -7625,6 +7884,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -7669,6 +7929,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -7733,6 +7994,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -7777,6 +8039,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -7821,6 +8084,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -7865,6 +8129,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -9249,6 +9514,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -9361,6 +9627,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -9473,6 +9740,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -9585,6 +9853,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -9661,6 +9930,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -9737,6 +10007,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -9814,6 +10085,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -9892,6 +10164,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -9968,6 +10241,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -10044,6 +10318,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -10119,6 +10394,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -10191,6 +10467,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -10333,6 +10610,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -10476,6 +10754,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -10633,6 +10912,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -10710,6 +10990,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -10752,6 +11033,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -10828,6 +11110,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -10896,6 +11179,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -11035,6 +11319,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a" @@ -11256,6 +11541,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a" @@ -11477,6 +11763,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -11617,6 +11904,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -11757,6 +12045,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -11896,6 +12185,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -12035,6 +12325,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a" @@ -12211,6 +12502,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -12347,6 +12639,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -12422,6 +12715,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -12497,6 +12791,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -12574,6 +12869,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -12642,6 +12938,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -12719,6 +13016,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -12850,6 +13148,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -12962,6 +13261,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -13039,6 +13339,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -13107,6 +13408,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -13219,6 +13521,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -13394,6 +13697,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -13569,6 +13873,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -13744,6 +14049,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a" @@ -13920,6 +14226,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -14032,6 +14339,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -14144,6 +14452,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -14256,6 +14565,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -14392,6 +14702,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -14504,6 +14815,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -14590,6 +14902,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -14719,6 +15032,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -14805,6 +15119,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -14871,6 +15186,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -15046,6 +15362,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -15218,6 +15535,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -15393,6 +15711,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -15565,6 +15884,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -15642,6 +15962,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -15770,6 +16091,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -16002,6 +16324,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -16121,6 +16444,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -16207,6 +16531,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a" @@ -16380,6 +16705,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -16552,6 +16878,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -16638,6 +16965,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -16766,6 +17094,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a" @@ -17002,6 +17331,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a" @@ -17238,6 +17568,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -17473,6 +17804,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -17693,6 +18025,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -17907,6 +18240,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -18127,6 +18461,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -18344,6 +18679,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -18519,6 +18855,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -18721,6 +19058,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], @@ -18923,6 +19261,7 @@ "kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4" }, + "raised_at": "2026-11-10T09:30:00Z", "deciders": [ "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" ], diff --git a/contracts/cultnet/governance/fork-card.md b/contracts/cultnet/governance/fork-card.md index 73ae333c..d642f98b 100644 --- a/contracts/cultnet/governance/fork-card.md +++ b/contracts/cultnet/governance/fork-card.md @@ -26,11 +26,11 @@ All documents are CultNet map-mode: every key written, nil where absent, closed ### card -`{schemaVersion, fork_id, title, context, options[{id, text}], recommendation, depends[], leans, flag, raised_in, deciders[], status, ruling, ruled_from[], order, options_hash}`. +`{schemaVersion, fork_id, title, context, options[{id, text}], recommendation, depends[], leans, flag, raised_in, raised_at, deciders[], status, ruling, ruled_from[], order, options_hash}`. - `fork_id`: the mind's `question` key, `:question:` over B, registry null / {A} / {A,B} | R / R / R | no cacheless write | no cacheless write | no cacheless write | | P4 cache {A,B}: remove k and upsert A at k in one batch | C (k is A's) | n/a | n/a | n/a | | P5 cache {A,B}: remove, then add in a second commit | C | n/a | n/a | n/a | diff --git a/packages/cultcache-ts/CHANGELOG.md b/packages/cultcache-ts/CHANGELOG.md index 8a4dd9c4..e71821c8 100644 --- a/packages/cultcache-ts/CHANGELOG.md +++ b/packages/cultcache-ts/CHANGELOG.md @@ -18,6 +18,12 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following - A `put`, `push` or `pushAll` that would land a second type under a held key (against the file, or within one batch) is refused with `SchemaConflictError`, and nothing is written. The written type may replace the record when its catalog entry carries the stored schema id as its own or lists it compatible (a type renamed under a stable id restamps the record, as C# does); otherwise retyping a key is a delete, then a put. The C# allowance for an undeclared older id of the same schema name that reads whole is not ported: a same-named type keeps passing, including one that drops persisted slots, which C# refuses. - Same-type writes replace by key; in one `pushAll` batch the last entry for a key wins. - A store whose canonical-schema catalog slot is not a string is refused as `StoreUnreadableError`. +- The cache holds one record per key (variants:ruling:cache-refuses-retype). A `put` or `putEnvelope` that lands a second type under a key the cache holds, or under a key a store carries foreign, is refused with `SchemaConflictError` naming the key, even with no store (a memory-only cache used to hold both types at the key). A type that owns the held record's schema id (as for the store) replaces it, unless the held record is in a store other than the written type's home: that rename is refused with `SchemaConflictError` (remove the record first). Two stores holding one key, registered or foreign, refuse the load and admit nothing. A store's `delete` removes the record whose stored schema id the removal's type owns, whatever the arrived record's `type` string. +- A store holding a record no registered type claims now loads (it used to throw "No schema is registered..."): the cache does not serve the record, lists it in `foreignRecords()`, keeps its bytes through every write, and refuses a `put` or a `delete` at its key with `SchemaConflictError` (variants:ruling:unheld-removal-refused-at-commit). Registering a type that claims its schema id serves it at the next `pullAllBackingStores`. + +### Added + +- `CultCache.foreignRecords()`: the `{key, schemaId, schemaName, storedAt}` of every record the last pull carried and no registered type claims, sorted by key ordinal. ### Changed diff --git a/packages/cultcache-ts/README.md b/packages/cultcache-ts/README.md index 21e76701..2096d258 100644 --- a/packages/cultcache-ts/README.md +++ b/packages/cultcache-ts/README.md @@ -231,6 +231,16 @@ its exact decimal expansion; NaN and infinities have no value. `roles` name sele member's catalog `indexAlias`, so the catalog bytes are the same either way); they imply nothing about uniqueness and are never indexed for lookup, so `getByIndex` on a role answers `undefined`. An alias is declared in one of the two. +### One record per key, and foreign records + +A cache holds one record per key whatever its type (variants:ruling:cache-refuses-retype). A write of a type that does not +own the held record's schema id (it carries that id as its own or lists it compatible) is refused with +`SchemaConflictError` naming the key, with or without a store; retyping a key is `delete` then `put`. A store that holds a +record no registered type claims still loads: the cache does not serve it, `foreignRecords()` lists it (`key`, +`schemaId`, `schemaName`, `storedAt`, by key ordinal), every write keeps its bytes, and a `put` or `delete` at its key is +refused. Registering a type that claims its schema id serves it at the next `pullAllBackingStores`. Two stores that hold +one key refuse the load and admit nothing. + ### Migrating an existing `indexes` key Apply per key, and do it BEFORE the CultLib checkout your tools load is rebuilt or updated. A consumer that loads diff --git a/packages/cultcache-ts/src/browser.ts b/packages/cultcache-ts/src/browser.ts index bf62976f..668fc0b8 100644 --- a/packages/cultcache-ts/src/browser.ts +++ b/packages/cultcache-ts/src/browser.ts @@ -2,7 +2,7 @@ // (node:fs, koffi). Bring your own CacheBackingStore, or none. export * as codecs from "./codec"; export type { Codec, SlotsCodec } from "./codec"; -export { CultCache, CultCacheBuilder } from "./cult-cache"; +export { CultCache, CultCacheBuilder, type CultCacheForeignRecord } from "./cult-cache"; export { inspectCultCacheBytes } from "./cult-cache-inspector"; export { decodeDocumentPayload, diff --git a/packages/cultcache-ts/src/cult-cache.ts b/packages/cultcache-ts/src/cult-cache.ts index c5bb663d..443c0c18 100644 --- a/packages/cultcache-ts/src/cult-cache.ts +++ b/packages/cultcache-ts/src/cult-cache.ts @@ -9,7 +9,7 @@ import { } from "./document"; import { resolveDocumentFormatter } from "./document-formatter"; import type { DocumentPayload } from "./document"; -import { SchemaConflictError } from "./store-format"; +import { SchemaConflictError, schemaIdForEnvelope, writtenOwns } from "./store-format"; import type { AnyCultCacheDocumentDefinition, CacheBackingStore, @@ -64,6 +64,17 @@ type HydratedState = { // delete of that global removes it from the home store. Remove once no store holds a global under // a key other than GLOBAL_KEY. readonly legacyGlobals: Map; + // The records the last pull found that no registered type claims. Derived from the pull and replaced with the state; it + // decides nothing but the refusal of a write or a removal at its keys. + readonly foreign: CultCacheForeignRecord[]; +}; + +/** A record a store carries that no registered type claims: the cache does not serve it, keeps its bytes, and refuses a write or a removal at its key. */ +export type CultCacheForeignRecord = { + readonly key: string; + readonly schemaId: string; + readonly schemaName: string; + readonly storedAt: string; }; // Lookups for every held record of one type under candidate accessors, derived before anything is installed. @@ -81,6 +92,7 @@ function emptyHydratedState(): HydratedState { indexToKeyMaps: new Map(), globalKeys: new Map(), legacyGlobals: new Map(), + foreign: [], }; } @@ -280,11 +292,14 @@ export class CultCache { for (const entry of entries) { const registered = this.#resolveDefinitionForEnvelope(entry); if (!registered) { - throw new Error( - entry.schemaId - ? `No schema is registered for persisted schema id "${entry.schemaId}".` - : `No schema is registered for persisted document type "${entry.type}".`, - ); + // A store may hold what this cache has no type for; it is carried, not served, and not a reason to refuse the load. + next.foreign.push({ + key: entry.key, + schemaId: schemaIdForEnvelope(entry), + schemaName: entry.catalogEntry?.schemaName ?? entry.type, + storedAt: entry.storedAt, + }); + continue; } const type = registered.definition.type; @@ -313,10 +328,27 @@ export class CultCache { } } const hydrated = this.#admitEntry(next, registered, admitted, value); + const other = this.#heldUnderOtherType(next, type, hydrated.key); + if (other !== undefined) { + throw new Error(`Record ${hydrated.key} is held by two stores, as type "${other.type}" and as type "${type}".`); + } this.#installEntry(next, registered, hydrated); } } + const foreignByKey = new Map(); + for (const record of next.foreign) { + const first = foreignByKey.get(record.key); + if (first !== undefined) { + throw new Error(`Record ${record.key} is held by two stores, as schema "${first}" and as schema "${record.schemaName}".`); + } + foreignByKey.set(record.key, record.schemaName); + const other = [...this.#definitions.keys()].map((type) => next.entries.get(this.#entryId(type, record.key))).find((held) => held !== undefined); + if (other !== undefined) { + throw new Error(`Record ${record.key} is held by two stores, as type "${other.type}" and as schema "${record.schemaName}".`); + } + } + // Judged whole, once: the refusal names the least violation of the state this load would leave, whatever the // order of the stores and of the entries in them. refuseLeastIndexSharing(function* () { @@ -553,6 +585,11 @@ export class CultCache { }); } + // What the last pull found that no registered type claims, by key ordinal. A later registration claims a record at the next pull. + foreignRecords(): readonly CultCacheForeignRecord[] { + return [...this.#hydrated.foreign].sort((left, right) => compareOrdinal(left.key, right.key)); + } + snapshot(): CultCacheEnvelope[] { return [...this.#hydrated.entries.values()].map((entry) => this.#toEnvelope(entry)); } @@ -662,6 +699,17 @@ export class CultCache { // is touched; once the store accepts, applying to the cache has no fallible step. async #writeNow(registered: RegisteredDefinition, entry: CultCacheEnvelope, value: unknown): Promise { const home = this.#homeStore(entry.type); + const retyped = this.#refuseRetype(entry); + if (retyped !== undefined && this.#homeStore(retyped.type) !== home) { + // The write owns the held record's id and would replace it, but the held record is in another store; the push + // reaches only this type's home, so the old record would stay in its own file and load as a second holder. + throw new SchemaConflictError( + schemaIdForEnvelope(entry), + [retyped.type, entry.type], + entry.key, + `CultCache holds record "${entry.key}" as type "${retyped.type}" in another store than the home of type "${entry.type}", so the rename cannot replace it; remove the record first.`, + ); + } const hydrated = this.#admitEntry(this.#hydrated, registered, entry, value); this.#refuseIndexSharing(this.#hydrated, entry.type, registered.catalogEntry.schemaName, entry.key, hydrated.lookups); const legacy = this.#hydrated.legacyGlobals.get(entry.type); @@ -670,9 +718,54 @@ export class CultCache { await this.#retireLegacyGlobal(home, legacy, entry); } this.#hydrated.legacyGlobals.delete(entry.type); + if (retyped !== undefined) { + // The record the write restamped (it owns the held id) is the same record: one key, one held type. + this.#removeEntry(this.#hydrated, this.#definitions.get(retyped.type)!, retyped); + } this.#installEntry(this.#hydrated, registered, hydrated); } + // The one key-identity judge of the cache: may this write land at its key, given what the cache holds there under + // every registered type and the keys its stores carry foreign. The store judges the file with the same predicate + // (writtenOwns); this judges the cache, which a store-less cache has no other judge for. Returns the held record of + // another type the write owns and so replaces, if any. A refusal names the key and schemas, never a payload. + #refuseRetype(entry: CultCacheEnvelope): HydratedEntry | undefined { + this.#refuseForeignKey(entry.key, entry.type, "written"); + const held = this.#heldUnderOtherType(this.#hydrated, entry.type, entry.key); + if (held === undefined || writtenOwns(entry, schemaIdForEnvelope(held))) { + return held; + } + throw new SchemaConflictError( + schemaIdForEnvelope(entry), + [held.type, entry.type], + entry.key, + `CultCache holds record "${entry.key}" as type "${held.type}", so type "${entry.type}" cannot be written under the same key; remove the record first.`, + ); + } + + #refuseForeignKey(key: string, type: string, what: "written" | "removed"): void { + const foreign = this.#hydrated.foreign.find((record) => record.key === key); + if (foreign !== undefined) { + throw new SchemaConflictError( + foreign.schemaId, + [foreign.schemaName], + key, + `CultCache store carries record "${key}" as schema "${foreign.schemaName}" (${foreign.schemaId}), which no registered type claims, so type "${type}" cannot be ${what} at that key.`, + ); + } + } + + // The record another registered type holds at the key, if any. + #heldUnderOtherType(state: HydratedState, type: string, key: string): HydratedEntry | undefined { + for (const other of this.#definitions.keys()) { + const held = other === type ? undefined : state.entries.get(this.#entryId(other, key)); + if (held !== undefined) { + return held; + } + } + return undefined; + } + // The first write of a global loaded from a legacy key removes that record in the same write. If the // removal fails, the new record is taken back out, so the store keeps only the legacy record. async #retireLegacyGlobal(home: CacheBackingStore, legacy: CultCacheEnvelope, written: CultCacheEnvelope): Promise { @@ -691,6 +784,9 @@ export class CultCache { const registered = this.#requireDefinition(definition); const entry = this.#hydrated.entries.get(this.#entryId(definition.type, key)); if (!entry) { + // variants:ruling:unheld-removal-refused-at-commit: a key a store carries foreign is refused, a key held under + // another type or by nobody is the existing false. + this.#refuseForeignKey(key, definition.type, "removed"); return false; } diff --git a/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts b/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts index 3f484ad8..5dfcfc74 100644 --- a/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts +++ b/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts @@ -7,7 +7,7 @@ import { z } from "zod"; import { documentPayloadBytes, documentPayloadFromBytes } from "./document"; import { commitStoreFile, withStoreWriteLock } from "./store-file"; -import { STORE_FORMAT_VERSION, SchemaConflictError, type StoreFormat, StoreUnreadableError, isStoreSnapshot, refuseUnreadStoreBytes, requireStoreSlots, requireV1RecordSlots } from "./store-format"; +import { STORE_FORMAT_VERSION, SchemaConflictError, type StoreFormat, StoreUnreadableError, isStoreSnapshot, refuseUnreadStoreBytes, requireStoreSlots, requireV1RecordSlots, schemaIdForEnvelope, writtenOwns } from "./store-format"; import type { CacheBackingStore, CultCacheEnvelope, @@ -71,8 +71,10 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore { const disk = this.#readDisk(); this.#format = disk.format; const existing = disk.envelopes; + // The record the file stores at the key is the one removed when the removal's schema owns its stored id: the + // arrived record's type is its schema's name, which a type of another name does not carry. const filtered = existing.filter( - (candidate) => !(candidate.type === entry.type && candidate.key === entry.key), + (candidate) => !(candidate.key === entry.key && (schemaIdForEnvelope(candidate) === schemaIdForEnvelope(entry) || writtenOwns(entry, schemaIdForEnvelope(candidate)))), ); await this.#writeAll([], filtered); }); @@ -138,14 +140,6 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore { } } -// As C# WrittenOwns' declared half: the written type owns the stored schema id when its catalog entry carries that id as its own -// or lists it compatible, so a type renamed under a stable id restamps the record it already holds. (The C# half that lets an -// undeclared older id of the same schema name through when it reads whole is not ported; a same-named type keeps passing.) -function writtenOwns(written: CultCacheEnvelope, storedId: string): boolean { - const entry = written.catalogEntry; - return entry !== undefined && (entry.schemaId === storedId || (entry.compatibleSchemaIds ?? []).includes(storedId)); -} - // A store holds a key once, and the reader refuses a file that holds it twice, so a write that would put a second type under a // key (against a record on disk, or within its own batch) is refused before anything is written. Retyping a record is a delete // of the old type followed by a put of the new one. @@ -383,10 +377,6 @@ function defaultEntryFor(entry: CultCacheEnvelope): CultCacheSchemaCatalogEntry }; } -function schemaIdForEnvelope(entry: CultCacheEnvelope): string { - return entry.schemaId ?? entry.type; -} - function encodeCatalogEntry(entry: CultCacheSchemaCatalogEntry): unknown[] { return [ entry.schemaId, diff --git a/packages/cultcache-ts/src/store-format.ts b/packages/cultcache-ts/src/store-format.ts index 4efab504..acd10207 100644 --- a/packages/cultcache-ts/src/store-format.ts +++ b/packages/cultcache-ts/src/store-format.ts @@ -1,5 +1,7 @@ import { decode } from "@msgpack/msgpack"; +import type { CultCacheEnvelope } from "./types"; + export const STORE_FORMAT_VERSION = "cultcache.store.v1"; /** @@ -151,3 +153,19 @@ export function requireStoreSlots(decoded: unknown[]): void { throw new Error(`CultCache store has ${decoded.length} top-level slots; this runtime reads ${STORE_SLOTS} (header, schema catalog, records).`); } } + +/** The schema id a record is stored under: the one it carries, else its type. */ +export function schemaIdForEnvelope(entry: CultCacheEnvelope): string { + return entry.schemaId ?? entry.type; +} + +/** + * As C# WrittenOwns' declared half: the written type owns the stored schema id when its catalog entry carries that id as its own + * or lists it compatible, so a type renamed under a stable id restamps the record it already holds. (The C# half that lets an + * undeclared older id of the same schema name through when it reads whole is not ported; a same-named type keeps passing.) + * The one predicate of the key-identity rule: the store judges the file with it and the cache judges what it holds. + */ +export function writtenOwns(written: CultCacheEnvelope, storedId: string): boolean { + const entry = written.catalogEntry; + return entry !== undefined && (entry.schemaId === storedId || (entry.compatibleSchemaIds ?? []).includes(storedId)); +} diff --git a/packages/cultcache-ts/test/cult-cache.test.ts b/packages/cultcache-ts/test/cult-cache.test.ts index 76f3d084..b575170f 100644 --- a/packages/cultcache-ts/test/cult-cache.test.ts +++ b/packages/cultcache-ts/test/cult-cache.test.ts @@ -266,10 +266,10 @@ test("CultCache fails closed on unknown or illegal persisted polymorphic state", ])) .build(); - await assert.rejects( - async () => cacheWithUnknownType.pullAllBackingStores(), - /No schema is registered for persisted document type "forbidden"\./, - ); + // A record no registered type claims is carried, not a reason to refuse the load. + await cacheWithUnknownType.pullAllBackingStores(); + assert.deepEqual(cacheWithUnknownType.snapshot(), []); + assert.deepEqual(cacheWithUnknownType.foreignRecords().map((record) => [record.key, record.schemaName]), [["mystery", "forbidden"]]); const cacheWithDuplicateGlobal = CultCache.builder() .withDocumentType(settingsDocument) @@ -2653,19 +2653,21 @@ test("SingleFileMessagePackBackingStore replaces a file exactly when it reads", // One key is held once: a push whose type does not own the stored schema id is refused, so the writer cannot emit a store its own // reader refuses; one whose type owns it (its own id, or one it lists compatible, as C# WrittenOwns) restamps the record in place. test("SingleFileMessagePackBackingStore push asks whether the written type owns the stored id, not what the types are called", async () => { - const seed = CultCache.builder().withDocumentType(ownsId).withDocumentType(listsId).build(); - await seed.put(ownsId, "k", { name: "owner" }); - await seed.put(listsId, "k", { name: "lister" }); + // One cache holds one record per key, so each envelope is made in a cache of its own. + const owner = CultCache.builder().withDocumentType(ownsId).build(); + const lister = CultCache.builder().withDocumentType(listsId).build(); + await owner.put(ownsId, "k", { name: "owner" }); + await lister.put(listsId, "k", { name: "lister" }); const file = join(await mkdtemp(join(tmpdir(), "cultcache-push-key-")), "store.msgpack"); const store = new SingleFileMessagePackBackingStore(file); const idsOf = async () => ((decode(await readFile(file)) as unknown[][])[2] as unknown[][]).map((record) => [record[0], record[1]]); - await store.push(seed.getRequiredEnvelope(listsId, "k")); - await assert.rejects(() => store.push(seed.getRequiredEnvelope(ownsId, "k")), SchemaConflictError); + await store.push(lister.getRequiredEnvelope(listsId, "k")); + await assert.rejects(() => store.push(owner.getRequiredEnvelope(ownsId, "k")), SchemaConflictError); assert.deepEqual(await idsOf(), [["k", "id.lister"]]); - await store.pushAll([seed.getRequiredEnvelope(listsId, "k")]); - await store.push(seed.getRequiredEnvelope(listsId, "k")); + await store.pushAll([lister.getRequiredEnvelope(listsId, "k")]); + await store.push(lister.getRequiredEnvelope(listsId, "k")); assert.equal((await store.pullAll()).length, 1); }); diff --git a/packages/cultcache-ts/test/key-identity-vector.test.ts b/packages/cultcache-ts/test/key-identity-vector.test.ts new file mode 100644 index 00000000..372614fd --- /dev/null +++ b/packages/cultcache-ts/test/key-identity-vector.test.ts @@ -0,0 +1,377 @@ +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import { copyFile, mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { test } from "node:test"; + +import { decode, encode } from "@msgpack/msgpack"; +import { z } from "zod"; + +import { CultCache } from "../src/cult-cache"; +import { defineDocumentType, documentPayloadBytes, documentPayloadFromBytes } from "../src/document"; +import { SingleFileMessagePackBackingStore, readSingleFileStore } from "../src/single-file-messagepack-backing-store"; +import { SchemaConflictError } from "../src/store-format"; +import type { AnyCultCacheDocumentDefinition, CultCacheEnvelope } from "../src/types"; + +// variants:ruling:cache-refuses-retype, write-ownership-by-written-type, unheld-removal-refused-at-commit. The vectors in +// tests/vectors/key-identity are written by the C# reference (see its README for the rule and the row shapes); this walks +// every row against the TS cache. A leaf keeps no verdict of its own. + +function findVectors(from: string): string { + for (let dir = from; ; dir = resolve(dir, "..")) { + if (existsSync(join(dir, "tests", "vectors", "key-identity"))) return join(dir, "tests", "vectors", "key-identity"); + if (resolve(dir, "..") === dir) throw new Error("tests/vectors/key-identity not found above the test."); + } +} +const vectors = findVectors(__dirname); + +// One string slot [0: Text]; the schema ids are the ones C# wrote (README). The type is the schema name, the name a record +// read back from a store carries as its type (the store's delete matches on it). +function kiType(type: string, id: string) { + const schemaName = `tests.ki_${type}`; + return defineDocumentType({ + type: schemaName, + schemaId: `sha256:${id}`, + schemaName, + schemaVersion: `${schemaName}.v1`, + schema: z.object({ text: z.string() }), + formatter: { + encode: (value: { text: string }) => encode([value.text]), + decode: (payload: Uint8Array) => ({ text: (decode(payload) as [string])[0] }), + }, + }); +} +const types: Record = { + alpha: kiType("alpha", "b20703a98f49c253898404ab50ba2c630720f0b8a5e1a41614d5650be22799fe"), + beta: kiType("beta", "dee4b727cf2ee04e61ab635ff0c5f3dca7345a411dc5f1018e583ab16009cf94"), + gamma: kiType("gamma", "fbc26e351e00c9e7185e8de171c7486aba707c229c949f0aae6b889288af5f6d"), +}; + +type Op = [string, ...unknown[]]; +type Row = [string, string[], Op[], [[string] | null, [string, string, string][], [string, string][] | null, string[], string[]]]; + +const compare = (left: string, right: string) => (left < right ? -1 : left > right ? 1 : 0); +const textOf = (envelope: CultCacheEnvelope) => (decode(documentPayloadBytes(envelope.payload)) as [string])[0]; +const stored = (path: string) => new Map(readSingleFileStore(path).envelopes.map((e) => [e.key, e])); +const sameRecord = (left: CultCacheEnvelope | undefined, right: CultCacheEnvelope | undefined) => + left !== undefined && right !== undefined && left.schemaId === right.schemaId && left.storedAt === right.storedAt && + Buffer.compare(Buffer.from(documentPayloadBytes(left.payload)), Buffer.from(documentPayloadBytes(right.payload))) === 0; + +test("vectors rows.msgpack: every row's refusal, held records, file, foreign list and kept bytes", async () => { + const rows = decode(await readFile(join(vectors, "rows.msgpack"))) as Row[]; + assert.equal(rows.length, 12); + let walked = 0; + const failures: string[] = []; + for (const [label, registered, ops, expect] of rows) { + const dir = await mkdtemp(join(tmpdir(), "ki-")); + try { + const [refusal, held, file, foreign, kept] = expect; + let counter = 0; + let path: string | undefined; + let cache!: CultCache; + let before: Map | undefined; + const attached: string[] = []; + const build = (store: string | undefined) => { + let builder = CultCache.builder(); + for (const name of registered) builder = builder.withDocumentType(types[name]!); + if (store !== undefined) builder = builder.withGenericStore(new SingleFileMessagePackBackingStore(store)); + return builder.build(); + }; + const fresh = async (name: string) => { + const copy = join(dir, `${counter++}-${name}`); + await copyFile(join(vectors, name), copy); + return copy; + }; + + let refused: unknown; + let failed = false; + let beforeBytes: Buffer | undefined; + let beforeCache: CultCacheEnvelope[] = []; + let beforeAttached: Buffer[] = []; + for (const [index, op] of ops.entries()) { + const last = index === ops.length - 1; + if (last) { + beforeCache = cache?.snapshot() ?? []; + beforeAttached = await Promise.all(attached.map((copy) => readFile(copy))); + beforeBytes = path !== undefined ? await readFile(path) : undefined; + } + try { + switch (op[0]) { + case "open": { + const arg = op[1] as string | null; + path = arg === "memory" ? undefined : arg === null ? join(dir, "fresh.cc") : await fresh(arg); + if (path !== undefined && arg !== null) before = stored(path); + cache = build(path); + await cache.pullAllBackingStores(); + break; + } + case "attach": { + const copy = await fresh(op[1] as string); + attached.push(copy); + await cache.addBackingStore(new SingleFileMessagePackBackingStore(copy), ...(op[2] as string[]).map((name) => types[name]!)); + await cache.pullAllBackingStores(); + break; + } + case "reopen": + cache = build(path); + await cache.pullAllBackingStores(); + break; + case "put": + await cache.put(types[op[1] as string]!, op[2] as string, { text: op[3] as string } as never); + break; + case "delete": + await cache.delete(types[op[1] as string]!, op[2] as string); + break; + default: + assert.fail(`${label}: unknown op ${op[0]}`); + } + } catch (error) { + if (!last) throw error; + refused = error; + failed = true; + } + } + + if (refusal === null) { + assert.equal(failed, false, `${label}: the last op lands (${String(refused)})`); + } else { + assert.equal(failed, true, `${label}: the last op is refused`); + const [key] = refusal; + if (label === "key-in-two-stores-refused") { + // The key is read out of the refusal as a delimited token: the message opens "Record is held by" and the key + // occurs nowhere else in it (not inside a schema name). + const message = (refused as Error).message; + const named = /^Record (\S+) is held by/u.exec(message); + assert.equal(named?.[1], key, `${label}: the refusal opens with the key`); + assert.equal(message.split(/[^A-Za-z0-9_.]+/u).filter((token) => token === key).length, 1, `${label}: the key occurs only as a delimited token`); + assert.deepEqual(cache.snapshot(), beforeCache, `${label}: the earlier pulled state survives`); + assert.deepEqual(await Promise.all(attached.slice(0, beforeAttached.length).map((copy) => readFile(copy))), beforeAttached, `${label}: the attached files are byte-identical`); + } else { + assert.ok(refused instanceof SchemaConflictError, `${label}: a typed schema conflict, got ${String(refused)}`); + assert.equal((refused as SchemaConflictError).recordKey, key, `${label}: names the key`); + assert.deepEqual(cache.snapshot(), beforeCache, `${label}: the cache is unchanged`); + if (path !== undefined) assert.deepEqual(await readFile(path), beforeBytes, `${label}: the store is byte-identical`); + } + } + + assert.deepEqual( + cache.snapshot() + .map((e) => [e.type.replace("tests.ki_", ""), e.key, textOf(e)] as [string, string, string]) + .sort((l, r) => compare(l[0], r[0]) || compare(l[1], r[1])), + held, + `${label}: held`, + ); + if (path === undefined) { + assert.equal(file, null, `${label}: no store`); + } else { + assert.deepEqual( + [...stored(path).values()] + .map((e) => [e.key, e.catalogEntry?.schemaName ?? e.type] as [string, string]) + .sort((l, r) => compare(l[0], r[0])), + file, + `${label}: file`, + ); + if (before !== undefined) { + const after = stored(path); + assert.deepEqual(foreign.filter((key) => sameRecord(before!.get(key), after.get(key))), kept, `${label}: kept`); + } + } + assert.deepEqual(cache.foreignRecords().map((record: { key: string }) => record.key), foreign, `${label}: foreign`); + walked++; + } catch (error) { + failures.push(`${label}: ${error instanceof Error ? error.message.split("\n")[0] : String(error)}`); + } finally { + await rm(dir, { recursive: true, force: true }); + } + } + assert.deepEqual(failures, [], "every row holds its verdict"); + assert.equal(walked, 12); +}); + +// Beyond the vector: what the walker's string-typed rows cannot say. +const CANARY = "CANARY-ki-77c1"; +const plain = (type: string, schemaId: string, compatibleSchemaIds?: string[]) => + defineDocumentType({ type, schemaId, schemaName: type, schemaVersion: `${type}.v1`, schema: z.object({ v: z.string() }), ...(compatibleSchemaIds ? { compatibleSchemaIds } : {}) }); + +test("a refusal names the key and schemas and never echoes the written value", async () => { + const alpha = plain("cn.alpha", "cn.alpha.v1"); + const beta = plain("cn.beta", "cn.beta.v1"); + const cache = CultCache.builder().withDocumentType(alpha).withDocumentType(beta).build(); + await cache.put(alpha, "k", { v: "first" }); + await assert.rejects(cache.put(beta, "k", { v: CANARY }), (error: Error) => { + assert.ok(error instanceof SchemaConflictError && error.recordKey === "k"); + assert.ok(!error.message.includes(CANARY)); + return true; + }); + assert.deepEqual(cache.get(alpha, "k"), { v: "first" }); + assert.equal(cache.get(beta, "k"), undefined); + await assert.rejects(cache.putEnvelope(beta, { key: "k", type: "cn.beta", payload: documentPayloadFromBytes(encode({ v: CANARY })), storedAt: "2026-01-01T00:00:00Z" }), SchemaConflictError); + assert.equal(cache.snapshot().length, 1); +}); + +test("a type that owns the held record's id replaces it: one record at the key, no second type held", async () => { + const old = plain("cn.old", "cn.id.v1"); + const renamed = plain("cn.new", "cn.id.v2", ["cn.id.v2", "cn.id.v1"]); + const cache = CultCache.builder().withDocumentType(old).withDocumentType(renamed).build(); + await cache.put(old, "k", { v: "a" }); + await cache.put(renamed, "k", { v: "b" }); + assert.deepEqual(cache.snapshot().map((e) => [e.type, e.key]), [["cn.new", "k"]]); + assert.equal(cache.get(old, "k"), undefined); +}); + +test("a store carrying an unregistered record loads, lists it sorted, keeps it through writes of other keys, and a later registration claims it", async () => { + const dir = await mkdtemp(join(tmpdir(), "ki-")); + try { + const path = join(dir, "s.cc"); + await copyFile(join(vectors, "foreign-store.cc"), path); + const open = (...names: string[]) => { + let builder = CultCache.builder(); + for (const name of names) builder = builder.withDocumentType(types[name]!); + return builder.withGenericStore(new SingleFileMessagePackBackingStore(path)).build(); + }; + const cache = open("alpha"); + await cache.pullAllBackingStores(); + const record = stored(path).get("k")!; + assert.deepEqual(cache.foreignRecords(), [ + { key: "k", schemaId: "sha256:fbc26e351e00c9e7185e8de171c7486aba707c229c949f0aae6b889288af5f6d", schemaName: "tests.ki_gamma", storedAt: record.storedAt }, + ]); + await cache.put(types.alpha!, "z", { text: "z" } as never); + await cache.delete(types.alpha!, "j"); + assert.ok(sameRecord(stored(path).get("k"), record), "the foreign record's stored bytes survive"); + assert.equal(await cache.delete(types.alpha!, "nobody"), false); + + const claimed = open("alpha", "gamma"); + await claimed.pullAllBackingStores(); + assert.deepEqual(claimed.foreignRecords(), []); + assert.equal(claimed.snapshot().length, 2); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("a foreign key one store carries and another holds under a registered type refuses the load and admits nothing", async () => { + const dir = await mkdtemp(join(tmpdir(), "ki-")); + try { + const foreign = join(dir, "foreign.cc"); + const catalog = join(dir, "catalog.cc"); + await copyFile(join(vectors, "catalog-store.cc"), catalog); + const writer = CultCache.builder().withDocumentType(types.gamma!).withGenericStore(new SingleFileMessagePackBackingStore(foreign)).build(); + await writer.put(types.gamma!, "qx", { text: "g" } as never); + const cache = CultCache.builder().withDocumentType(types.alpha!).build(); + await cache.addBackingStore(new SingleFileMessagePackBackingStore(catalog), types.alpha!); + await cache.pullAllBackingStores(); + await cache.addBackingStore(new SingleFileMessagePackBackingStore(foreign)); + await assert.rejects(cache.pullAllBackingStores(), /held by two stores/u); + assert.deepEqual(cache.snapshot().map((e) => e.key), ["qx"], "the earlier pulled state survives"); + assert.deepEqual(cache.foreignRecords(), []); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +// The cache judge answers for what the cache holds whatever the store is: a store that never judges (it only records +// what it is asked) is asked nothing at a foreign key, and foreign records list by key ordinal. +test("a foreign key is refused by the cache before any store is asked, and foreign records list sorted", async () => { + const calls: string[] = []; + const record = (key: string, schemaId: string): CultCacheEnvelope => ({ + key, + type: "unclaimed", + schemaId, + storedAt: "2026-01-01T00:00:00Z", + payload: documentPayloadFromBytes(encode(["x"])), + }); + const store = { + pullAll: async () => [record("m", "unclaimed.m.v1"), record("b", "unclaimed.b.v1")], + push: async (entry: CultCacheEnvelope) => void calls.push(`push ${entry.key}`), + delete: async (entry: CultCacheEnvelope) => void calls.push(`delete ${entry.key}`), + }; + const cache = CultCache.builder().withDocumentType(types.alpha!).withGenericStore(store).build(); + await cache.pullAllBackingStores(); + assert.deepEqual(cache.foreignRecords().map((r) => [r.key, r.schemaId, r.schemaName]), [["b", "unclaimed.b.v1", "unclaimed"], ["m", "unclaimed.m.v1", "unclaimed"]]); + await assert.rejects(cache.put(types.alpha!, "m", { text: CANARY } as never), (error: Error) => { + assert.ok(error instanceof SchemaConflictError && error.recordKey === "m"); + assert.ok(!error.message.includes(CANARY)); + return true; + }); + await assert.rejects(cache.delete(types.alpha!, "b"), (error: Error) => error instanceof SchemaConflictError && error.recordKey === "b"); + assert.equal(await cache.delete(types.alpha!, "nobody"), false); + assert.deepEqual(calls, []); +}); + +// The store's delete matches the record the file stores by its schema id, not by the type string: a record read back is +// typed by its schema's name, which a type of another name does not carry. +test("a delete removes the record the file stores although the type differs from the schema name, and the key retypes across a reload", async () => { + const dir = await mkdtemp(join(tmpdir(), "ki-")); + try { + const path = join(dir, "s.cc"); + const named = (type: string, schemaName: string, schemaId: string) => + defineDocumentType({ type, schemaId, schemaName, schemaVersion: `${schemaName}.v1`, schema: z.object({ v: z.string() }) }); + const first = named("app.first", "wire.first", "wire.first.v1"); + const second = named("app.second", "wire.second", "wire.second.v1"); + const open = () => CultCache.builder().withDocumentType(first).withDocumentType(second).withGenericStore(new SingleFileMessagePackBackingStore(path)).build(); + const writer = open(); + await writer.put(first, "k", { v: "a" }); + const reader = open(); + await reader.pullAllBackingStores(); + assert.equal(await reader.delete(first, "k"), true); + assert.deepEqual([...stored(path).keys()], [], "the record is gone from the file"); + await reader.put(second, "k", { v: "b" }); + assert.deepEqual([...stored(path).values()].map((e) => [e.key, e.catalogEntry?.schemaName]), [["k", "wire.second"]]); + const again = open(); + await again.pullAllBackingStores(); + assert.deepEqual(again.snapshot().map((e) => [e.type, e.key]), [["app.second", "k"]]); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("an owned rename whose two types home in different stores is refused, and neither store changes", async () => { + const dir = await mkdtemp(join(tmpdir(), "ki-")); + try { + const oldPath = join(dir, "old.cc"); + const newPath = join(dir, "new.cc"); + const old = plain("rn.old", "rn.id.v1"); + const renamed = plain("rn.new", "rn.id.v2", ["rn.id.v2", "rn.id.v1"]); + const cache = CultCache.builder().withDocumentType(old).withDocumentType(renamed).build(); + await cache.addBackingStore(new SingleFileMessagePackBackingStore(oldPath), old); + await cache.addBackingStore(new SingleFileMessagePackBackingStore(newPath), renamed); + await cache.put(old, "k", { v: "a" }); + const before = await readFile(oldPath); + await assert.rejects(cache.put(renamed, "k", { v: CANARY }), (error: Error) => { + assert.ok(error instanceof SchemaConflictError && error.recordKey === "k"); + assert.ok(!error.message.includes(CANARY)); + return true; + }); + assert.deepEqual(await readFile(oldPath), before); + assert.equal(existsSync(newPath), false, "the new home was never written"); + assert.deepEqual(cache.snapshot().map((e) => [e.type, e.key]), [["rn.old", "k"]]); + // Same home: the rename replaces. + const together = CultCache.builder().withDocumentType(old).withDocumentType(renamed).withGenericStore(new SingleFileMessagePackBackingStore(join(dir, "one.cc"))).build(); + await together.put(old, "k", { v: "a" }); + await together.put(renamed, "k", { v: "b" }); + assert.deepEqual(together.snapshot().map((e) => [e.type, e.key]), [["rn.new", "k"]]); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("two stores each carrying an unregistered record at one key refuse the load and admit nothing", async () => { + const dir = await mkdtemp(join(tmpdir(), "ki-")); + try { + const paths = [join(dir, "a.cc"), join(dir, "b.cc")]; + for (const path of paths) { + const writer = CultCache.builder().withDocumentType(types.gamma!).withGenericStore(new SingleFileMessagePackBackingStore(path)).build(); + await writer.put(types.gamma!, "k", { text: "g" } as never); + } + // Neither store's records are claimed: gamma is not registered here. + const cache = CultCache.builder().withDocumentType(types.alpha!).withDocumentType(types.beta!).build(); + await cache.addBackingStore(new SingleFileMessagePackBackingStore(paths[0]!), types.alpha!); + await cache.addBackingStore(new SingleFileMessagePackBackingStore(paths[1]!), types.beta!); + await assert.rejects(cache.pullAllBackingStores(), /^Error: Record k is held by two stores/u); + assert.deepEqual(cache.foreignRecords(), []); + assert.deepEqual(cache.snapshot(), []); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); From a0184785a2a40367ace1f4a05dba2c27fd4751de Mon Sep 17 00:00:00 2001 From: Libby Date: Thu, 12 Nov 2026 00:45:11 +0000 Subject: [PATCH 42/93] Python cache: one record per key, foreign carry, key-identity vector walked (#122) --- docs/write-ownership-map.md | 4 +- packages/cultcache-py/CHANGELOG.md | 3 + packages/cultcache-py/README.md | 6 +- .../cultcache-py/src/cultcache_py/__init__.py | 3 +- .../cultcache-py/src/cultcache_py/cache.py | 102 +++++- .../cultcache-py/src/cultcache_py/stores.py | 8 +- .../tests/test_key_identity_vector.py | 316 ++++++++++++++++++ packages/cultnet-py/CHANGELOG.md | 1 + packages/cultnet-py/tests/test_cultnet.py | 31 +- 9 files changed, 458 insertions(+), 16 deletions(-) create mode 100644 packages/cultcache-py/tests/test_key_identity_vector.py diff --git a/docs/write-ownership-map.md b/docs/write-ownership-map.md index be188c3c..3bce4ad2 100644 --- a/docs/write-ownership-map.md +++ b/docs/write-ownership-map.md @@ -33,8 +33,8 @@ Probes: scratch tests in this worktree, removed. C# filtered runs on Starfire (o | Probe | C# | TypeScript | Rust | Python | |---|---|---|---|---| -| P1 cache {A,B}: B at k, then A at k | **C**: B's record replaced, k now A's id | R `SchemaConflictError` (cultcache-ts: one record per key) | C, both live at k | C, both live at k | -| P2 cache {A} only, store holds B at k, write A at k | R `CultSchemaConflictException` (B is foreign, carried) | R `SchemaConflictError` at write (foreign loads, carried; cultcache-ts) | R at load, same | R at load: "Unknown persisted document type" | +| P1 cache {A,B}: B at k, then A at k | **C**: B's record replaced, k now A's id | R `SchemaConflictError` (cultcache-ts: one record per key) | C, both live at k | R `SchemaConflictError`, A's write refused, B kept (was C, both live) | +| P2 cache {A} only, store holds B at k, write A at k | R `CultSchemaConflictException` (B is foreign, carried) | R `SchemaConflictError` at write (foreign loads, carried; cultcache-ts) | R at load, same | R `SchemaConflictError` (B is foreign, carried; the load admits it; was R at load) | | P3 cacheless `WriteDocument` over B, registry null / {A} / {A,B} | R / R / R | no cacheless write | no cacheless write | no cacheless write | | P4 cache {A,B}: remove k and upsert A at k in one batch | C (k is A's) | n/a | n/a | n/a | | P5 cache {A,B}: remove, then add in a second commit | C | n/a | n/a | n/a | diff --git a/packages/cultcache-py/CHANGELOG.md b/packages/cultcache-py/CHANGELOG.md index e0026b8e..95f82042 100644 --- a/packages/cultcache-py/CHANGELOG.md +++ b/packages/cultcache-py/CHANGELOG.md @@ -9,6 +9,9 @@ ### Breaking +- A cache holds one record per key whatever its type (`variants:ruling:cache-refuses-retype`). A `put`, `put_envelope`, `put_envelopes` or `put_global` of a second type under a key the cache holds, or under a key a store carries foreign, is refused with `SchemaConflictError` naming the key, even with no store, and changes nothing; retype a key by deleting it, then putting the new type. A write may replace another registered type's record only when the written type owns its schema id (declares it or lists it compatible) and both types share one home store. A load that reads one key from two stores, whatever the types, is refused with `CultCacheError` naming the key and both types, and admits nothing. A store that holds a record no registered type claims now loads and is carried: the cache does not serve it, the store keeps its bytes through every write, `delete` or `put` at its key is refused with `SchemaConflictError`, and registering a type that declares its schema id claims it at the next load. It raised `CultCacheError` "Unknown persisted document type". Two global types share the key `__global__`, so a second global type in one cache is now refused, and a store that already holds two global types at `__global__` (a JSONL store written by caches that never pulled could produce one) no longer loads: the pull raises `CultCacheError` and admits nothing. C# refuses a second persisted global on pull the same way (`cultcache-store-composition.md`, "Globals never invented"); repair such a file by deleting one of the two records with the runtime that wrote it. +- Added `foreign_records()` and the frozen `ForeignRecord(key, schema_id, schema_name, stored_at)`, sorted by key in UTF-16 code-unit order. `stores.retyped` and `stores.written_owns` lose their underscore: the cache judge and the single-file store's commit guard ask one predicate. + - A single-file store holding two records under one key is unreadable: the v1 reader and the legacy envelope-array reader refuse it with `StoreUnreadableError` (a key is compared by its exact code points, no case folding or normalisation). A catalog slot whose canonical schema is not a string is refused the same way. A store that loaded before can now be refused: repair it with the runtime that wrote the duplicate. - A `put` or `push_all` that would land a second type under a key the store holds is refused with `SchemaConflictError`, and the file is left as it was. Retype a key by deleting it, then putting the new type. A write replaces the record at a key only when the written type owns it: its catalog entry declares the stored schema id (its own, or one it lists compatible), so a renamed type that lists the old id compatible may replace the record, or it has the stored record's schema name. This runtime does not compare shapes, so a same-name type is never refused for dropping persisted slots as C# refuses it. - Writes of the same type replace by key: the last record wins, in a batch as in the file. diff --git a/packages/cultcache-py/README.md b/packages/cultcache-py/README.md index 236009ed..43e6c90e 100644 --- a/packages/cultcache-py/README.md +++ b/packages/cultcache-py/README.md @@ -19,7 +19,11 @@ discovery, sessions, local server) build on it, mirroring `cultcache-rs` / `[format, schemaCatalog, records]` - records store raw MessagePack payload bytes under schema-catalog identity - payloads decode only through the registered document definition for that type -- unknown persisted types fail closed +- a cache holds one record per key whatever its type: a write of a second type at a held key is refused with + `SchemaConflictError` (in memory too), and two stores holding one key refuse the load +- a store record no registered type claims loads and is carried as foreign: `cache.foreign_records()` lists it, the store + keeps its bytes through every write, a write or removal at its key is refused, and registering a type that declares its + schema id claims it at the next load - global documents are singleton-style per type - one home store per document type: a store added with `add_backing_store` owns its types, otherwise the generic store is the home; a type claimed by a diff --git a/packages/cultcache-py/src/cultcache_py/__init__.py b/packages/cultcache-py/src/cultcache_py/__init__.py index aabd21bf..dc73ab1b 100644 --- a/packages/cultcache-py/src/cultcache_py/__init__.py +++ b/packages/cultcache-py/src/cultcache_py/__init__.py @@ -1,7 +1,7 @@ from .backing_store import BackingStore, CultCacheEnvelope from .backing_store import CultCacheSchemaCatalogEntry, CultCacheSchemaCatalogMember from . import leaves -from .cache import CultCache, CultCacheBuilder +from .cache import CultCache, CultCacheBuilder, ForeignRecord from .documents import ( MAX_PAYLOAD_DEPTH, MAX_SLOT_KEY, @@ -29,6 +29,7 @@ __all__ = [ "CultCacheSchemaCatalogMember", "DatabaseEntryField", "DocumentDefinition", + "ForeignRecord", "JsonLinesBackingStore", "MAX_PAYLOAD_DEPTH", "MAX_SLOT_KEY", diff --git a/packages/cultcache-py/src/cultcache_py/cache.py b/packages/cultcache-py/src/cultcache_py/cache.py index 9c402968..315e64ac 100644 --- a/packages/cultcache-py/src/cultcache_py/cache.py +++ b/packages/cultcache-py/src/cultcache_py/cache.py @@ -9,7 +9,7 @@ from typing import Any, Callable, Generic, Iterable, TypeVar from .backing_store import BackingStore, CultCacheEnvelope from .documents import DocumentDefinition, extract_value -from .stores import SchemaConflictError +from .stores import SchemaConflictError, retyped, written_owns T = TypeVar("T") F = TypeVar("F", bound=Callable[..., Any]) @@ -120,6 +120,20 @@ class _State: # of that global removes it from the home store. Remove once no store holds a global under a key # other than GLOBAL_KEY. legacy_global_keys: dict[str, str] = field(default_factory=dict) + # The records the last pull read that no registered type claims, sorted by key. Derived from the pull and swapped with the + # rest of the state, written nowhere else; it decides only refusals at its keys. A store keeps their bytes. + foreign: list["ForeignRecord"] = field(default_factory=list) + + +@dataclass(frozen=True) +class ForeignRecord: + """A record a store holds that no registered type claims: the cache does not serve it, refuses a write or a removal at its + key, and a store keeps its bytes. Registering a type that declares its schema id claims it at the next load.""" + + key: str + schema_id: str + schema_name: str + stored_at: str @dataclass(frozen=True) @@ -322,15 +336,23 @@ class CultCache: loaded_legacy_global_keys: dict[str, str] = {} routes, generic = self._state.stores_by_type, self._state.generic_store seen_globals: set[str] = set() + foreign: list[ForeignRecord] = [] + type_at_key: dict[str, tuple[str, int]] = {} # one record per key across every store: its type (or schema name) and store stores: list[BackingStore] = [] for candidate in [*self._state.stores_by_type.values(), self._state.generic_store]: if candidate is not None and all(candidate is not store for store in stores): stores.append(candidate) - for store in stores: + for position, store in enumerate(stores): for envelope in store.pull_all(): document = self._resolve_document_for_envelope(envelope) if document is None: - raise CultCacheError(f"Unknown persisted document type: {envelope.type}") + entry = envelope.catalog_entry + held_name = entry.schema_name if entry is not None else envelope.type + self._claim_key(type_at_key, envelope.key, held_name, position) + foreign.append( + ForeignRecord(envelope.key, envelope.schema_id or envelope.type, held_name, envelope.stored_at) + ) + continue if envelope.type != document.type: envelope = CultCacheEnvelope( key=envelope.key, @@ -353,6 +375,7 @@ class CultCache: if envelope.key != GLOBAL_KEY: loaded_legacy_global_keys[envelope.type] = envelope.key envelope = replace(envelope, key=GLOBAL_KEY) + self._claim_key(type_at_key, envelope.key, envelope.type, position) envelope = self._stamped(document, envelope) value = document.decode_payload(envelope.payload) loaded_values.setdefault(envelope.type, {})[envelope.key] = value @@ -369,7 +392,28 @@ class CultCache: self._state.indexes, self._state.lookup_keys, self._state.legacy_global_keys, - ) = (loaded_values, loaded_envelopes, loaded_names, loaded_indexes, loaded_lookup_keys, loaded_legacy_global_keys) + self._state.foreign, + ) = ( + loaded_values, + loaded_envelopes, + loaded_names, + loaded_indexes, + loaded_lookup_keys, + loaded_legacy_global_keys, + sorted(foreign, key=lambda record: _utf16_order(record.key)), + ) + + @staticmethod + def _claim_key(type_at_key: dict[str, tuple[str, int]], key: str, type: str, store: int) -> None: + """A key holds one record whatever its type, across every store a load reads. The refusal names the key and both types, + never a payload.""" + held, held_store = type_at_key.setdefault(key, (type, store)) + if held != type or held_store != store: + raise CultCacheError(f"Record {key!r} is held by both {held} and {type}: a key holds one record; the load admitted nothing.") + + def foreign_records(self) -> list[ForeignRecord]: + """The records the last load read that no registered type claims, sorted by key (UTF-16 code-unit order).""" + return list(self._state.foreign) def get(self, document: DocumentDefinition[T], key: str) -> T | None: self._assert_registered(document) @@ -497,6 +541,7 @@ class CultCache: (envelope, value, self._lookup_keys_for(value, name_extractor, index_extractors)) for envelope, value in records ] + displaced = self._refuse_retype(document, [envelope for envelope, _ in records]) self._refuse_index_sharing( [(type, envelope.key, keys) for envelope, _, keys in admitted], self._state.indexes, @@ -515,6 +560,8 @@ class CultCache: values = self._state.values.setdefault(type, {}) envelopes = self._state.envelopes.setdefault(type, {}) lookup_keys = self._state.lookup_keys.setdefault(type, {}) + for other, key in displaced: + self._drop(other, key) for envelope, value, keys in admitted: self._remove_lookups(type, envelope.key) values[envelope.key] = value @@ -525,6 +572,39 @@ class CultCache: for index, index_value in keys.indexes.items(): self._state.indexes[(type, index, index_value)] = envelope.key + def _refuse_retype(self, document: DocumentDefinition[Any], written: list[CultCacheEnvelope]) -> list[tuple[str, str]]: + """The one key-identity judge (variants:ruling:cache-refuses-retype): a key holds one record whatever its type, so a write + may not land at a key this cache holds another type's record at, unless the written type owns that record's schema id + (the single-file store's commit guard asks the same question of the file), and may not land at a key a store carries + foreign. Returns the (type, key) records an owning write replaces; refuses, changing nothing, otherwise.""" + replaced: list[tuple[str, str]] = [] + foreign = {record.key: record for record in self._state.foreign} + for envelope in written: + carried = foreign.get(envelope.key) + if carried is not None: + raise self._foreign_refusal(carried) + for other, held_envelopes in self._state.envelopes.items(): + held = held_envelopes.get(envelope.key) + if other == document.type or held is None: + continue + if not written_owns(held, envelope) or self._store_for_type(other) is not self._store_for_type(document.type): + raise retyped(held, envelope) + replaced.append((other, envelope.key)) + return replaced + + @staticmethod + def _foreign_refusal(record: ForeignRecord) -> SchemaConflictError: + return SchemaConflictError( + record.schema_id, + [record.schema_name], + record.key, + message=( + f"Record {record.key!r} is held by the store as a {record.schema_name} (schema id {record.schema_id!r}) that no " + "registered type claims; a write or removal there would replace what this cache cannot read. Register the type " + "that owns the schema id, or remove the record with the runtime that wrote it." + ), + ) + @staticmethod def _retire_legacy_global(store: BackingStore, type: str, legacy_key: str) -> None: """The first write of a global loaded from a legacy key removes that record in the same write. If @@ -564,19 +644,27 @@ class CultCache: def _delete(self, document: DocumentDefinition[Any], key: str) -> None: self._assert_registered(document) + if key not in self._state.envelopes.get(document.type, {}): + carried = next((record for record in self._state.foreign if record.key == key), None) + if carried is not None: + raise self._foreign_refusal(carried) store = self._store_for_type(document.type) legacy_key = self._state.legacy_global_keys.get(document.type) if key == GLOBAL_KEY else None if store is not None: store.delete(document.type, key if legacy_key is None else legacy_key) if legacy_key is not None: del self._state.legacy_global_keys[document.type] - self._remove_lookups(document.type, key) + self._drop(document.type, key) + + def _drop(self, type: str, key: str) -> None: + """Removes the held record and its lookups from the cache's maps; the store is the caller's.""" + self._remove_lookups(type, key) for by_type in (self._state.values, self._state.envelopes, self._state.lookup_keys): - held = by_type.get(document.type) + held = by_type.get(type) if held is not None: held.pop(key, None) if not held: - by_type.pop(document.type, None) + by_type.pop(type, None) @_mutating def delete_global(self, document: DocumentDefinition[Any]) -> None: diff --git a/packages/cultcache-py/src/cultcache_py/stores.py b/packages/cultcache-py/src/cultcache_py/stores.py index e0cfdd7a..61841956 100644 --- a/packages/cultcache-py/src/cultcache_py/stores.py +++ b/packages/cultcache-py/src/cultcache_py/stores.py @@ -245,7 +245,7 @@ def _derive_catalog(records: list[tuple[CultCacheEnvelope, bool]]) -> list[CultC return [entry for entry, _ in chosen.values()] -def _retyped(held: CultCacheEnvelope, written: CultCacheEnvelope) -> SchemaConflictError: +def retyped(held: CultCacheEnvelope, written: CultCacheEnvelope) -> SchemaConflictError: """The refusal of a write that would put a second type under a key the store already holds (C# CacheBackingStore.Retyped).""" return SchemaConflictError( _schema_id_for(held), @@ -258,7 +258,7 @@ def _retyped(held: CultCacheEnvelope, written: CultCacheEnvelope) -> SchemaConfl ) -def _written_owns(held: CultCacheEnvelope, written: CultCacheEnvelope) -> bool: +def written_owns(held: CultCacheEnvelope, written: CultCacheEnvelope) -> bool: """Whether a write of `written` may replace `held` at its key (C# WrittenOwns): the written type's entry declares the held record's schema id (its own id or one it lists compatible), or it is the type of the same schema name. The same-name half stands in for C#'s "reads the published entry whole": this runtime does not compare shapes, so it never refuses a @@ -277,8 +277,8 @@ def _encode_snapshot( landing: dict[str, CultCacheEnvelope] = {} for envelope in supplied: for held in (landing.get(envelope.key), held_by_key.get(envelope.key)): - if held is not None and not _written_owns(held, envelope): - raise _retyped(held, envelope) + if held is not None and not written_owns(held, envelope): + raise retyped(held, envelope) landing[envelope.key] = envelope supplied = list(landing.values()) replaced = set(landing) diff --git a/packages/cultcache-py/tests/test_key_identity_vector.py b/packages/cultcache-py/tests/test_key_identity_vector.py new file mode 100644 index 00000000..065601e7 --- /dev/null +++ b/packages/cultcache-py/tests/test_key_identity_vector.py @@ -0,0 +1,316 @@ +from __future__ import annotations + +import re +import shutil +import tempfile +import unittest +from pathlib import Path + +import msgpack + +from cultcache_py.cache import CultCacheError +from cultcache_py import ( + CultCache, + + JsonLinesBackingStore, + SchemaConflictError, + SingleFileMessagePackBackingStore, + database_entry_field, + define_database_entry_type, + leaves as t, +) + +# variants:ruling:cache-refuses-retype, write-ownership-by-written-type, unheld-removal-refused-at-commit. The vector in +# tests/vectors/key-identity is written only by the C# reference; this walks every row against the Python cache. +VECTORS = Path(__file__).resolve().parents[3] / "tests" / "vectors" / "key-identity" + +IDS = { + "alpha": "sha256:b20703a98f49c253898404ab50ba2c630720f0b8a5e1a41614d5650be22799fe", + "beta": "sha256:dee4b727cf2ee04e61ab635ff0c5f3dca7345a411dc5f1018e583ab16009cf94", + "gamma": "sha256:fbc26e351e00c9e7185e8de171c7486aba707c229c949f0aae6b889288af5f6d", +} +DOCUMENTS = { + label: define_database_entry_type( + f"tests.ki_{label}", + [database_entry_field("text", 0, codec=t.string)], + schema_id=schema_id, + schema_name=f"tests.ki_{label}", + ) + for label, schema_id in IDS.items() +} +LABEL_OF = {document.type: label for label, document in DOCUMENTS.items()} + +def file_records(path: Path) -> list[list[str]]: + return sorted([envelope.key, envelope.type] for envelope in SingleFileMessagePackBackingStore(path).pull_all()) + +def stored(path: Path, key: str): + for envelope in SingleFileMessagePackBackingStore(path).pull_all(): + if envelope.key == key: + return envelope.schema_id, envelope.stored_at, envelope.payload + return None + +class KeyIdentityVectorTests(unittest.TestCase): + def test_rows(self) -> None: + rows = msgpack.unpackb((VECTORS / "rows.msgpack").read_bytes(), raw=False) + walked = 0 + for label, registered, ops, expect in rows: + with self.subTest(row=label), tempfile.TemporaryDirectory() as tmp: + self.walk(Path(tmp), registered, ops, expect) + walked += 1 + self.assertEqual(walked, 12) + + def walk(self, tmp: Path, registered: list[str], ops: list, expect: list) -> None: + refusal, held, file, foreign, kept = expect + state: dict = {"cache": None, "path": None, "before": None, "attached": []} + + def builder() -> CultCache.builder: + made = CultCache.builder() + for name in registered: + made.register_document_type(DOCUMENTS[name]) + return made + + def open_cache(target) -> None: + if target == "memory": + state["cache"], state["path"] = builder().build(), None + return + path = tmp / "store.cc" + if target is not None and target != path: + shutil.copyfile(VECTORS / target, path) + state["before"] = path.read_bytes() + state["path"] = path + made = builder().add_generic_store(SingleFileMessagePackBackingStore(path)) + state["cache"] = made.build() + state["cache"].pull_all_backing_stores() + + def perform(op: list) -> None: + cache = state["cache"] + if op[0] == "open": + open_cache(op[1]) + elif op[0] == "attach": + path = tmp / f"attached-{len(state['attached'])}.cc" + shutil.copyfile(VECTORS / op[1], path) + state["attached"].append((path, path.read_bytes())) + cache.add_backing_store( + SingleFileMessagePackBackingStore(path), [DOCUMENTS[name].type for name in op[2]] + ) + cache.pull_all_backing_stores() + elif op[0] == "reopen": + open_cache(state["path"]) # the same file, no fresh copy + elif op[0] == "put": + cache.put(DOCUMENTS[op[1]], op[2], {"text": op[3]}) + elif op[0] == "delete": + cache.delete(DOCUMENTS[op[1]], op[2]) + else: + self.fail(f"unknown op {op[0]}") + + for op in ops[:-1]: + perform(op) + if refusal is None: + perform(ops[-1]) + else: + key = refusal[0] + with self.assertRaises((SchemaConflictError, ValueError, RuntimeError)) as caught: + perform(ops[-1]) + if ops[-1][0] == "attach": + # The key is read out of the refusal's own text (it opens "Record 'key' is held by") and occurs only as a + # delimited token, never inside a schema name or other word. + message = str(caught.exception) + named = re.match(r"Record '([^']*)' is held by", message) + self.assertIsNotNone(named, message) + self.assertEqual(named.group(1), key) + self.assertEqual(len(re.findall(rf"(? CultCache: + made = CultCache.builder() + for name in names: + made.register_document_type(DOCUMENTS[name]) + if store is not None: + made.add_generic_store(store) + return made.build() + +class KeyIdentityTests(unittest.TestCase): + def test_two_global_types_are_refused_in_one_cache_and_a_store_holding_both_does_not_load(self) -> None: + # C# refuses a second persisted global on pull (cultcache-store-composition.md, "Globals never invented"); the Python + # JSONL store and a cache with no store used to let two global types share __global__, so such a file can exist. + ga = define_database_entry_type( + "tests.ki_ga", [database_entry_field("text", 0, codec=t.string)], schema_id="sha256:" + "1" * 64, global_document=True + ) + gb = define_database_entry_type( + "tests.ki_gb", [database_entry_field("text", 0, codec=t.string)], schema_id="sha256:" + "2" * 64, global_document=True + ) + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "store.jsonl" + for document in (ga, gb): # one cache per type that never pulled: how a file got two globals + writer = CultCache.builder().register_document_type(document).add_generic_store(JsonLinesBackingStore(path)).build() + writer.put_global(document, {"text": document.type}) + both = CultCache.builder().register_document_type(ga).register_document_type(gb).add_generic_store(JsonLinesBackingStore(path)).build() + with self.assertRaises(CultCacheError) as caught: + both.pull_all_backing_stores() + self.assertIn("'__global__' is held by both", str(caught.exception)) + self.assertEqual(both.snapshot(), {}) + memory = CultCache.builder().register_document_type(ga).register_document_type(gb).build() + memory.put_global(ga, {"text": "a"}) + with self.assertRaises(SchemaConflictError): + memory.put_global(gb, {"text": "b"}) + self.assertEqual(memory.get_required_global(ga), {"text": "a"}) + + def test_every_write_path_refuses_a_second_type_and_never_echoes_the_payload(self) -> None: + alpha, beta = DOCUMENTS["alpha"], DOCUMENTS["beta"] + for name in ("put", "put_envelope", "put_envelopes"): + with self.subTest(path=name): + cache = alpha_beta("alpha", "beta") + cache.put(alpha, "k", {"text": "held"}) + envelope = cache.get_envelope(alpha, "k") + other = type(envelope).create( + key="k", type=beta.type, payload=beta.encode_payload({"text": "CANARY"}), schema_id=IDS["beta"] + ) + writes = { + "put": lambda: cache.put(beta, "k", {"text": "CANARY"}), + "put_envelope": lambda: cache.put_envelope(beta, other), + "put_envelopes": lambda: cache.put_envelopes(beta, [other]), + } + with self.assertRaises(SchemaConflictError) as caught: + writes[name]() + self.assertEqual(caught.exception.record_key, "k") + self.assertNotIn("CANARY", str(caught.exception)) + self.assertEqual(cache.get(alpha, "k"), {"text": "held"}) + self.assertIsNone(cache.get(beta, "k")) + + def test_two_routed_stores_holding_one_key_refuse_the_pull_and_keep_the_earlier_state(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + one, two = Path(tmp) / "one.cc", Path(tmp) / "two.cc" + shutil.copyfile(VECTORS / "catalog-store.cc", one) + shutil.copyfile(VECTORS / "run-store.cc", two) + cache = alpha_beta("alpha", "beta") + cache.add_backing_store(SingleFileMessagePackBackingStore(one), [DOCUMENTS["alpha"].type]) + cache.pull_all_backing_stores() + cache.add_backing_store(SingleFileMessagePackBackingStore(two), [DOCUMENTS["beta"].type]) + with self.assertRaises(Exception) as caught: + cache.pull_all_backing_stores() + self.assertIn("'qx'", str(caught.exception)) + self.assertEqual(cache.get(DOCUMENTS["alpha"], "qx"), {"text": "a"}) + self.assertIsNone(cache.get(DOCUMENTS["beta"], "qx")) + + def test_a_foreign_record_is_listed_and_its_bytes_survive_writes_and_removals_elsewhere(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "store.cc" + shutil.copyfile(VECTORS / "foreign-store.cc", path) + before = stored(path, "k") + cache = alpha_beta("alpha", store=SingleFileMessagePackBackingStore(path)) + cache.pull_all_backing_stores() + [record] = cache.foreign_records() + self.assertEqual((record.key, record.schema_id, record.schema_name), ("k", IDS["gamma"], "tests.ki_gamma")) + self.assertEqual(record.stored_at, before[1]) + cache.put(DOCUMENTS["alpha"], "m", {"text": "m"}) + cache.delete(DOCUMENTS["alpha"], "j") + self.assertEqual(stored(path, "k"), before) + self.assertEqual(file_records(path), [["k", "tests.ki_gamma"], ["m", "tests.ki_alpha"]]) + claimed = alpha_beta("alpha", "gamma", store=SingleFileMessagePackBackingStore(path)) + claimed.pull_all_backing_stores() + self.assertEqual(claimed.foreign_records(), []) + self.assertEqual(claimed.get(DOCUMENTS["gamma"], "k"), {"text": "g"}) + + def test_the_foreign_list_is_sorted_by_utf16_code_unit_and_replaced_by_the_next_pull(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "store.cc" + store = SingleFileMessagePackBackingStore(path) + gamma = DOCUMENTS["gamma"] + writer = alpha_beta("gamma", store=store) + for key in ("\U00010400", "A", "b", "B"): + writer.put(gamma, key, {"text": "x"}) + cache = alpha_beta("alpha", store=SingleFileMessagePackBackingStore(path)) + cache.pull_all_backing_stores() + self.assertEqual([record.key for record in cache.foreign_records()], ["B", "b", "\U00010400", "A"]) + for key in ("B", "b", "\U00010400", "A"): + writer.delete(gamma, key) + cache.pull_all_backing_stores() + self.assertEqual(cache.foreign_records(), []) + +class OwnershipAndStoreKindTests(unittest.TestCase): + """The cache judge answers on its own where the store has no commit guard (JSONL) or the records live in two stores.""" + + def test_a_jsonl_store_gets_no_help_from_a_disk_judge(self) -> None: + from cultcache_py import JsonLinesBackingStore + + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "store.jsonl" + gamma = DOCUMENTS["gamma"] + alpha_beta("gamma", store=JsonLinesBackingStore(path)).put(gamma, "k", {"text": "g"}) + before = path.read_bytes() + cache = alpha_beta("alpha", store=JsonLinesBackingStore(path)) + cache.pull_all_backing_stores() + self.assertEqual([record.key for record in cache.foreign_records()], ["k"]) + with self.assertRaises(SchemaConflictError): + cache.put(DOCUMENTS["alpha"], "k", {"text": "x"}) + with self.assertRaises(SchemaConflictError): + cache.delete(DOCUMENTS["alpha"], "k") + self.assertEqual(path.read_bytes(), before) + cache.put(DOCUMENTS["alpha"], "j", {"text": "a"}) # the foreign line is carried through a write elsewhere + self.assertIn(b"tests.ki_gamma", path.read_bytes()) + + def test_a_foreign_key_in_two_stores_refuses_the_pull(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + one, two = Path(tmp) / "one.cc", Path(tmp) / "two.cc" + for path in (one, two): + alpha_beta("gamma", store=SingleFileMessagePackBackingStore(path)).put(DOCUMENTS["gamma"], "k", {"text": "g"}) + cache = alpha_beta("alpha") + cache.add_backing_store(SingleFileMessagePackBackingStore(one), [DOCUMENTS["alpha"].type]) + cache.add_backing_store(SingleFileMessagePackBackingStore(two), [DOCUMENTS["beta"].type]) + with self.assertRaises(Exception) as caught: + cache.pull_all_backing_stores() + self.assertIn("'k'", str(caught.exception)) + + def test_a_type_that_owns_the_held_schema_id_replaces_the_record_and_the_key_keeps_one_type(self) -> None: + successor = define_database_entry_type( + "tests.ki_alpha2", + [database_entry_field("text", 0, codec=t.string)], + schema_id="sha256:" + "a" * 64, + schema_name="tests.ki_alpha2", + compatible_schema_ids=[IDS["alpha"]], + ) + for routed in (False, True): + with self.subTest(routed=routed), tempfile.TemporaryDirectory() as tmp: + one, two = Path(tmp) / "one.cc", Path(tmp) / "two.cc" + made = CultCache.builder().register_document_type(DOCUMENTS["alpha"]).register_document_type(successor) + if routed: + made.add_backing_store(SingleFileMessagePackBackingStore(one), [DOCUMENTS["alpha"].type]) + made.add_backing_store(SingleFileMessagePackBackingStore(two), [successor.type]) + else: + made.add_generic_store(SingleFileMessagePackBackingStore(one)) + cache = made.build() + cache.put(DOCUMENTS["alpha"], "k", {"text": "old"}) + if routed: + with self.assertRaises(SchemaConflictError): # one key would live in two stores + cache.put(successor, "k", {"text": "new"}) + self.assertEqual(cache.get(DOCUMENTS["alpha"], "k"), {"text": "old"}) + else: + cache.put(successor, "k", {"text": "new"}) + self.assertIsNone(cache.get(DOCUMENTS["alpha"], "k")) + self.assertEqual(cache.get(successor, "k"), {"text": "new"}) + self.assertEqual(file_records(one), [["k", "tests.ki_alpha2"]]) diff --git a/packages/cultnet-py/CHANGELOG.md b/packages/cultnet-py/CHANGELOG.md index e7bd3afd..b8a3773e 100644 --- a/packages/cultnet-py/CHANGELOG.md +++ b/packages/cultnet-py/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased (breaking; version 0.x, so the next minor) +- `apply_raw_document_record` and `apply_raw_snapshot` raise `SchemaConflictError` when a remote record is of another type than the one the cache holds at its key (`cultcache-py` holds one record per key whatever its type); the cache is unchanged, and the caller decides whether to drop the record or the peer. `cultmesh-py`'s server answers such a put with `message_invalid`; its client's `read_next_change` lets the error out. - `CultNetDatabaseChange` carries `commit_id` and `commit_count` (`commitId`/`commitCount`; None outside a multi-record commit, as C# writes nil): `from_wire` refuses an empty or non-string id and a count that is not an integer of at least 2, `to_wire` writes them when set, and the published `database_change_raw` schema lists both. Python applies no batch. The `simulation_fact` document's `shard`, `frame`, `subject` and `claim_kind` aliases are `roles` (many facts share each); the catalog bytes do not move. - Decodes the write receipt frames of `contracts/cultnet/interop/write-receipt.md`: `parse_message` validates `cultnet.write_admitted.v0` and `cultnet.error.v1` against their closed schemas (unknown keys, empty or non-string ids, an unknown operation or code are refused; `error.v1` requires `messageId`, string or null; `limit_reached` is `error.v1` only). Added `CULTNET_ERROR_V1_CODES`, `CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS`, `write_content_hash` and `hello_advertises_write_receipts`; `hello` takes `supported_message_versions`. `tests/test_write_receipt.py` replays the vectors' `contentHash`, `wire` and `schemaInvalid` sections. Not implemented here: the client write tracker (`scenarios`) and the server rules (`dedupe`, `blankFields`); this package has no receipt server and no write client. - Added `message_invalid` to `CULTNET_ERROR_CODES` and `message_invalid_error(field, reason)`, byte-identical to the C# `ForMessageInvalid`. `cultmesh-py` refuses a malformed put or delete with it instead of the out-of-schema `malformed_document_put` and `malformed_document_delete`, which are removed. diff --git a/packages/cultnet-py/tests/test_cultnet.py b/packages/cultnet-py/tests/test_cultnet.py index 68f8f8ea..48972221 100644 --- a/packages/cultnet-py/tests/test_cultnet.py +++ b/packages/cultnet-py/tests/test_cultnet.py @@ -14,7 +14,7 @@ from pathlib import Path from typing import Callable from uuid import uuid4 import msgpack -from cultcache_py import CultCache, define_database_entry_type +from cultcache_py import CultCache, SchemaConflictError, define_database_entry_type from cultnet_py.benchmark import run_benchmark from cultnet_py.compare_csharp import ( DEFAULT_PARITY_THRESHOLD, @@ -1942,6 +1942,35 @@ class CultNetTests(unittest.TestCase): self.assertEqual(raised.exception.response["messageId"], "subscription-error") thread.join(2.0) + def test_replication_refuses_a_remote_record_of_another_type_at_a_held_key_and_changes_nothing(self) -> None: + # variants:ruling:cache-refuses-retype: a cache holds one record per key whatever its type, so a peer that reuses a + # key across types is refused where the record is applied; replication does not catch it. + first = define_database_entry_type("replica.first", [("name", 0), ("category", 1), ("value", 2, 0)], cls=Item) + second = define_database_entry_type("replica.second", [("name", 0), ("category", 1), ("value", 2, 0)], cls=Item) + cache = CultCache() + cache.register_document_type(first) + cache.register_document_type(second) + cache.put(first, "shared", Item("held", "", 0)) + + def raw(document): + return { + "schemaId": document.catalog_entry().schema_id, + "recordKey": "shared", + "storedAt": "2026-06-13T00:00:00Z", + "payloadEncoding": "messagepack", + "payload": document.encode_payload(Item("remote", "", 0)), + } + + with self.assertRaises(SchemaConflictError): + apply_raw_document_record(cache, schema_document_map([first, second]), raw(second)) + snapshot = {"schemaVersion": "cultnet.snapshot_response_raw.v0", "messageId": "s", "documents": [raw(second)]} + with self.assertRaises(SchemaConflictError): + apply_raw_snapshot(cache, [first, second], snapshot) + self.assertEqual(cache.get_required(first, "shared").name, "held") + self.assertIsNone(cache.get(second, "shared")) + apply_raw_document_record(cache, schema_document_map([first, second]), raw(first)) # the same type still replaces + self.assertEqual(cache.get_required(first, "shared").name, "remote") + def test_cultnet_replication_helpers_apply_raw_snapshot_and_shard_log(self) -> None: document = define_database_entry_type( "replica.item", From c37183273699e3f1e15ff7ea4597d0132c3e44e4 Mon Sep 17 00:00:00 2001 From: Libby Date: Thu, 12 Nov 2026 01:05:46 +0000 Subject: [PATCH 43/93] Rust cache: one record per key, foreign carry, key-identity vector walked (#123) --- docs/write-ownership-map.md | 26 +- packages/cultcache-rs/CHANGELOG.md | 2 + packages/cultcache-rs/README.md | 12 +- packages/cultcache-rs/src/lib.rs | 497 ++++++++++++++++-- .../cultcache-rs/tests/key_identity_vector.rs | 283 ++++++++++ packages/cultcache-rs/tests/unique_index.rs | 6 +- tests/vectors/key-identity/README.md | 3 + 7 files changed, 776 insertions(+), 53 deletions(-) create mode 100644 packages/cultcache-rs/tests/key_identity_vector.rs diff --git a/docs/write-ownership-map.md b/docs/write-ownership-map.md index 3bce4ad2..44452dc6 100644 --- a/docs/write-ownership-map.md +++ b/docs/write-ownership-map.md @@ -33,8 +33,8 @@ Probes: scratch tests in this worktree, removed. C# filtered runs on Starfire (o | Probe | C# | TypeScript | Rust | Python | |---|---|---|---|---| -| P1 cache {A,B}: B at k, then A at k | **C**: B's record replaced, k now A's id | R `SchemaConflictError` (cultcache-ts: one record per key) | C, both live at k | R `SchemaConflictError`, A's write refused, B kept (was C, both live) | -| P2 cache {A} only, store holds B at k, write A at k | R `CultSchemaConflictException` (B is foreign, carried) | R `SchemaConflictError` at write (foreign loads, carried; cultcache-ts) | R at load, same | R `SchemaConflictError` (B is foreign, carried; the load admits it; was R at load) | +| P1 cache {A,B}: B at k, then A at k | **C**: B's record replaced, k now A's id | R `SchemaConflictError` (cultcache-ts: one record per key) | R `SchemaConflictError` naming k, A not written (leaf cut) | R `SchemaConflictError`, A's write refused, B kept (was C, both live) | +| P2 cache {A} only, store holds B at k, write A at k | R `CultSchemaConflictException` (B is foreign, carried) | R `SchemaConflictError` at write (foreign loads, carried; cultcache-ts) | R `SchemaConflictError` (B loads as foreign, carried; leaf cut) | R `SchemaConflictError` (B is foreign, carried; the load admits it; was R at load) | | P3 cacheless `WriteDocument` over B, registry null / {A} / {A,B} | R / R / R | no cacheless write | no cacheless write | no cacheless write | | P4 cache {A,B}: remove k and upsert A at k in one batch | C (k is A's) | n/a | n/a | n/a | | P5 cache {A,B}: remove, then add in a second commit | C | n/a | n/a | n/a | @@ -55,12 +55,22 @@ What this establishes: - Retype as remove plus add already works in the cache (P4, P5); the cut adds no new primitive. - Directory store: its own judge (`DirectoryMessagePackBackingStore.cs:287-306`) asks `Registry.Owns` for every key, upserts included. It must take the same per-key rule. -- TypeScript, Rust, Python: records are keyed by `(type, key)` (Rust `entries: BTreeMap<(String, String), _>`, - `lib.rs:2659`; TS `#entryId(definition.type, key)`, `cult-cache.ts:323`; Python sorts and stores by `(type, key)`). - A write of A at k never meets B's record: both live at k (P1). None carries a foreign record; a store holding an - unregistered type does not load (P2). So no leaf has a "write meets another type's record" path to fix. Bringing the - ruling to the leaves first needs C#'s key identity (one record per key) and C#'s foreign carry, which are parity changes - of the leaves' store model, not a write-ownership rule. Owned by the new follow-up `variants:follow_up:leaf-key-identity-parity`. +- TypeScript and Python: records are keyed by `(type, key)` (TS `#entryId(definition.type, key)`, `cult-cache.ts:323`; + Python sorts and stores by `(type, key)`). A write of A at k never meets B's record: both live at k (P1). Neither carries + a foreign record; a store holding an unregistered type does not load (P2). So neither has a "write meets another type's + record" path to fix: bringing the ruling to them first needs C#'s key identity (one record per key) and C#'s foreign + carry, parity changes of their store model, not a write-ownership rule (follow-up `variants:follow_up:leaf-key-identity-parity`). +- Rust is that follow-up's first leaf cut (`cut-leaf-key-identity-rs`). The cache keeps its `(type, key)` map; one judge + (`refuse_retype`) refuses another registered type at a key the cache holds, or any write at a key a store carries + foreign, on every write path, pull and SoA load, comparing types, not the ids envelopes claim. At a key the cache does not + hold (a fresh key, or one another writer filled after this cache's pull) the single-file store's commit guard judges by + the stored and claimed schema ids, so an envelope claiming another registered type's id is refused earlier, at every + envelope admission (`refuse_claimed_id`, the pull's own `resolve_registered_type` lookup); an id naming no registered type + passes, trusted as claimed: an envelope under such an id can replace another writer's record stored under it at a key + this cache does not hold, or be served as another type by a reader that registers the type the id names (Soul g8a, g8c, + open: which ids a Rust type owns is not declared to CultCache). Store-level writers called directly (`push`, + `insert_entry_if_absent`, compare-and-swap) trust the claimed id. + The cache carries an unregistered type's records as foreign and walks the vector's 12 rows. - Interop (unproven): a Python store holding A and B at k fails C# decode first on payload encoding (Python's lambda-defined document wrote JSON payloads), so what C# does with two records at one key was not reached. diff --git a/packages/cultcache-rs/CHANGELOG.md b/packages/cultcache-rs/CHANGELOG.md index c32773d9..ed7cd60a 100644 --- a/packages/cultcache-rs/CHANGELOG.md +++ b/packages/cultcache-rs/CHANGELOG.md @@ -17,10 +17,12 @@ only in git history. - A single-file store holds one record per key. The v1 and legacy readers refuse a store with two records under one key (`CultCacheStoreUnreadable`, the record `Undecodable`): the store does not open, and a write over it refuses and leaves the file. A consumer that holds such a file must repair it with the runtime that wrote it before opening it here. - Every single-file writer (`push`, `push_all`, `insert_entry_if_absent`, the compare-exchange and snapshot-append family, and the cache's puts and batches) refuses to put a second type under a key the store holds, with `SchemaConflictError` naming the key and both types, writing nothing. Retyping a key is a delete, then an add (one batch may hold both). A write owns the held record when its schema id equals the id the held record is stored under, as C# `WrittenOwns` asks; a type renamed under a stable id therefore replaces its own record, and a write of another type name under another id is refused (`push` of the same type name under another id still replaces the record, as before; `compare_and_swap_entry`, and a `push_all` batch holding one type under two ids, refuse it). Registered types here declare no compatible ids, so equality is the whole test. Writers used to leave two records under the key. - `compare_and_swap_entry` over a single file refuses an in-place retype (expected `A/k`, replacement `B/k` under another schema id) with the same `SchemaConflictError`, as `compare_exchange` does, where it used to retype the record and return `true`. A lost race still returns `false` first. +- A cache holds one record per key. `put`, `put_envelope`, `put_raw_envelope` and a batch of a second type under a key the cache holds, or a key a store carries foreign, are refused with `SchemaConflictError` even with no store (a memory-only cache used to hold both), whatever schema id the envelope claims: another registered type's record is never owned. `put_envelope`, `put_raw_envelope`, `put_prepared_batch` and `load_soa` also refuse, at any key, an envelope whose schema id names another type registered in this cache (the id a pull would resolve the record by). An id this cache does not register is trusted as the envelope claims it: at a key this cache does not hold, the single-file store decides ownership by that id, so such an envelope can replace another writer's record stored under it, and a reader registering the type the id names serves the record as that type. Store-level writers called directly (`push`, `insert_entry_if_absent`, compare-and-swap) still trust the claimed id. A refused write or pull changes nothing. Two stores holding one key refuse the pull, naming the key and both types, and so does one store holding two records at a key that resolve to one type. A store holding a record no registered type claims now loads (it used to refuse with "No schema is registered for persisted entry type") and the record is carried, through `put_prepared_batch` too, which lays foreign records back byte for byte; `delete` at a foreign key is refused. `load_soa` refuses a second type at a key or a record at a foreign key, and still refuses an unregistered type. The redb stores still key by `(type, key)`, and their own writers (`push`, `insert_entry_if_absent`, compare-and-swap) still put a second type at a held key: a cache now refuses to pull such a redb store with `SchemaConflictError`, where it used to load both records. - A write of the same type under a held key replaces the record by key, and in one `push_all` the last given of several entries under a key and a schema id is the record, whatever their type names. The redb stores are unchanged and still key by type and key. ### Added +- `CultCache::foreign_records()` and `ForeignRecord { key, schema_id, schema_name, stored_at }`: the records the last pull found that no registered type claims, sorted by key. - Lookups, matching the C# `GetByName`, `GetByIndex` and `GetGlobal`. `#[cultcache(name)]` and `#[cultcache(index)]` / `#[cultcache(index = "alias")]` on `DatabaseEntry` fields and `#[cultcache(global)]` on the struct give `CultCache::get_by_name`, `get_by_index` and `get_global`. An empty name and a blank index value are not found; records of one type sharing a name find the one with the smallest key (an index value is unique, below). A second record of a global type under another key is refused on every write path and on load with `CultCacheSecondGlobal`. Two `name` members, or two members with one index alias, are refused at compile time. A member's text comes from `CultLookupValue` (`String`, `Option` of one, integers, `bool`, `char`). - `SchemaConflictError`, the typed refusal of a write the catalog cannot describe: records of different types under one schema id, or an arrived schema and a registered one that share an id and disagree on the schema name. A whole-store write (`push_all`) refuses with it, names the schema id, the names involved and a record key, writes nothing and leaves the file as it is. A caller tells it apart with `error.downcast_ref::()`. diff --git a/packages/cultcache-rs/README.md b/packages/cultcache-rs/README.md index 45724843..8a8b8f5e 100644 --- a/packages/cultcache-rs/README.md +++ b/packages/cultcache-rs/README.md @@ -21,8 +21,14 @@ CultCache is a domain cache with persistence adapters. - `CultCache` is the query and mutation surface. - Domain structs implement `DatabaseEntry`. -- Entries are stored behind a `type::key` identity, so multiple entry types - can share a logical key without colliding. +- A cache holds one record per key whatever its type. Records are held per + `(type, key)`, but a write of a second type under a key the cache holds, or a + key a store carries foreign, is refused with `SchemaConflictError` naming the + key (no store needed); retyping a key is a delete, then an add. A store + record no registered type claims loads: the cache does not serve it, lists it + in `foreign_records()`, lays it back byte for byte through every write + including `put_prepared_batch`, and refuses a write or delete at its key. + Registering a type that claims its schema id picks it up at the next pull. - Each payload is encoded directly from the known `DatabaseEntry` type into MessagePack bytes. - Backing stores are adapters, not the public data model. @@ -458,7 +464,7 @@ That is the Rust version of the original trick: - C# refuses to store objects outside the `DatabaseEntry` root type. - Runtime-visible `DatabaseEntry` subclasses get dedicated formatters. -- Unknown or unregistered types are rejected. +- A write of an unregistered type is rejected; a stored record of an unregistered type is carried as foreign, not decoded. - MessagePack payload bytes deserialize through known concrete formatters, not an open-world type loader or generic structured-value bridge. diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index f7b8cae0..686547f4 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -2750,6 +2750,18 @@ pub struct CultCache { /// values. Registration data, so the judge decodes only indexed types. projectors: BTreeMap, stores: Vec, + /// Records a store holds that no registered type claims: (store index, the envelope as the store read it). Derived from the last + /// pull and replaced with it; written nowhere else. It decides only refusals at its keys. + foreign: Vec<(usize, CultCacheEnvelope)>, +} + +/// A record a store holds that no registered type claims: carried, never served. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ForeignRecord { + pub key: String, + pub schema_id: String, + pub schema_name: String, + pub stored_at: String, } type IndexProjector = fn(&[u8]) -> Result>; @@ -2803,6 +2815,7 @@ impl CultCache { index_holders: BTreeMap::new(), projectors: BTreeMap::new(), stores: Vec::new(), + foreign: Vec::new(), } } @@ -2940,6 +2953,7 @@ impl CultCache { /// only when all of them loaded, so a refused load admits nothing. pub fn pull_all_backing_stores(&mut self) -> Result<()> { let mut loaded = BTreeMap::new(); + let mut foreign = Vec::new(); let known_types: BTreeSet = self.definitions.keys().cloned().collect(); let schema_name_definitions = self.schema_name_definitions.clone(); for (index, registration) in self.stores.iter().enumerate() { @@ -2950,10 +2964,10 @@ impl CultCache { entry.schema_id.as_deref(), &entry.r#type, ) else { - return Err(anyhow!( - "No schema is registered for persisted entry type {:?}", - entry.r#type - )); + // No registered type claims the record: the cache carries it, never serves it. + self.refuse_retype(&loaded, &foreign, &entry)?; + foreign.push((index, entry)); + continue; }; let home = self.resolve_route_index(&canonical_type); if home != Some(index) { @@ -2966,16 +2980,39 @@ impl CultCache { )); } entry.r#type = canonical_type; + self.refuse_retype(&loaded, &foreign, &entry)?; + // Two records of a store resolving to one type at one key (one stamped with the other's id): one record per key. + if let Some(held) = loaded.get(&entry_id(&entry)) { + return Err(retype_conflict(held, &entry)); + } loaded.insert(entry_id(&entry), entry); } } self.refuse_second_globals(&loaded)?; let holders = self.judge_image(&loaded)?; self.entries = loaded; + self.foreign = foreign; self.index_holders = holders; Ok(()) } + /// The records the last pull found in a store that no registered type claims, sorted by key (UTF-16 order). The cache does not + /// serve them; it carries them through every write and refuses a write or removal at their keys. + pub fn foreign_records(&self) -> Vec { + let mut records: Vec = self + .foreign + .iter() + .map(|(_, entry)| ForeignRecord { + key: entry.key.clone(), + schema_id: schema_identity(entry).to_string(), + schema_name: entry.r#type.clone(), + stored_at: entry.stored_at.clone(), + }) + .collect(); + records.sort_by(|left, right| utf16_cmp(&left.key, &right.key)); + records + } + /// Returns the exact envelopes in the currently loaded cache image. /// Callers that need optimistic concurrency can validate typed documents /// and capture CAS expectations from one coherent read. @@ -3130,6 +3167,7 @@ impl CultCache { pub fn put(&mut self, key: impl Into, value: &T) -> Result { let (entry, parsed) = self.prepare_entry(key, value)?; self.refuse_second_global(&self.entries, &entry.r#type, &entry.key)?; + self.refuse_retype(&self.entries, &self.foreign, &entry)?; let change = self.judge_unique(&[&entry])?; if let Some(home) = self.home_index(T::TYPE)? { self.stores[home].store.push(&entry)?; @@ -3228,6 +3266,7 @@ impl CultCache { )); } require_payload_depth(entry)?; + self.refuse_claimed_id(entry)?; let Some(home) = self.home_index(&entry.r#type)? else { continue; }; @@ -3244,13 +3283,18 @@ impl CultCache { next.insert(entry_id(entry), entry.clone()); } self.refuse_second_globals(&next)?; + for entry in &staged { + self.refuse_retype(&next, &self.foreign, entry)?; + } let change = self.judge_unique(&staged.iter().collect::>())?; if let Some(store_index) = store_index { - // The home store receives its own records only: those it already - // holds in this cache plus the staged ones. + // The home store receives its own records only: those it already holds in this cache plus the staged ones, and + // the records it carries that no registered type claims: push_all replaces the store's records, so a foreign + // record is laid back as the store read it. let snapshot: Vec<_> = next .values() .filter(|entry| self.resolve_route_index(&entry.r#type) == Some(store_index)) + .chain(self.foreign.iter().filter(|(home, _)| *home == store_index).map(|(_, entry)| entry)) .cloned() .collect(); self.stores[store_index] @@ -3264,7 +3308,9 @@ impl CultCache { pub fn put_envelope(&mut self, entry: CultCacheEnvelope) -> Result { let parsed = self.validate_envelope::(&entry)?; + self.refuse_claimed_id(&entry)?; self.refuse_second_global(&self.entries, &entry.r#type, &entry.key)?; + self.refuse_retype(&self.entries, &self.foreign, &entry)?; let change = self.judge_unique(&[&entry])?; if let Some(home) = self.home_index(T::TYPE)? { self.stores[home].store.push(&entry)?; @@ -3299,7 +3345,9 @@ impl CultCache { )); } require_payload_depth(&entry)?; + self.refuse_claimed_id(&entry)?; self.refuse_second_global(&self.entries, &entry.r#type, &entry.key)?; + self.refuse_retype(&self.entries, &self.foreign, &entry)?; let change = self.judge_unique(&[&entry])?; if let Some(home) = self.home_index(&entry.r#type)? { self.stores[home].store.push(&entry)?; @@ -3354,6 +3402,9 @@ impl CultCache { self.require_entry_type::()?; let id = entry_id_parts(T::TYPE, key); let Some(entry) = self.entries.get(&id).cloned() else { + if let Some((_, foreign)) = self.foreign.iter().find(|(_, foreign)| foreign.key == key) { + return Err(foreign_conflict(foreign, T::TYPE)); + } return Ok(false); }; let released = self.projected_slots(&entry)?; @@ -3387,6 +3438,8 @@ impl CultCache { )); } self.home_index(&entry.r#type)?; + self.refuse_claimed_id(&entry)?; + self.refuse_retype(&loaded, &self.foreign, &entry)?; loaded.insert(entry_id(&entry), entry); } self.refuse_second_globals(&loaded)?; @@ -3509,6 +3562,46 @@ impl CultCache { } } + /// The one key-identity judge (C# `RefuseUndeclared` and the cache's held-owned check): a cache holds one record per key + /// whatever its type. `written` may not land at a key a store carries foreign, nor at a key another registered type holds + /// in `image`. C# `WrittenOwns` lets a write replace a held record its own type's id owns; a registered type's id here is + /// its entry type and declares no compatible ids, so another type's record is never owned, whatever schema id either + /// envelope claims. Refused with a typed [`SchemaConflictError`] naming the key and both types, never a value; nothing is + /// changed. The single-file store's commit guard judges the same question for the file on disk (another writer's file) + /// and stays. + fn refuse_retype( + &self, + image: &BTreeMap<(String, String), CultCacheEnvelope>, + foreign: &[(usize, CultCacheEnvelope)], + written: &CultCacheEnvelope, + ) -> Result<()> { + if let Some((_, carried)) = foreign.iter().find(|(_, carried)| carried.key == written.key) { + return Err(foreign_conflict(carried, &written.r#type)); + } + for type_id in self.definitions.keys().filter(|type_id| **type_id != written.r#type) { + if let Some(held) = image.get(&(type_id.clone(), written.key.clone())) { + return Err(retype_conflict(held, written)); + } + } + Ok(()) + } + + /// An admitted envelope's schema id may not name another registered type: the pull resolves a record's type from that id + /// (`resolve_registered_type`), so admitting it would store one type that every reload serves as the other, at a key this + /// cache may not hold (another writer's record, or a fresh key). Admission asks the pull's own lookup. An id that names no + /// registered type (a dynamic or versioned id) is the envelope's own. Store-level writers called directly trust the id. + fn refuse_claimed_id(&self, entry: &CultCacheEnvelope) -> Result<()> { + let known_types: BTreeSet = self.definitions.keys().cloned().collect(); + match resolve_registered_type(&known_types, &self.schema_name_definitions, entry.schema_id.as_deref(), &entry.r#type) { + Some(resolved) if resolved != entry.r#type => Err(anyhow::Error::new(SchemaConflictError { + schema_id: schema_identity(entry).to_string(), + schema_names: vec![resolved, entry.r#type.clone()], + record_key: entry.key.clone(), + })), + _ => Ok(()), + } + } + fn refuse_second_globals(&self, entries: &BTreeMap<(String, String), CultCacheEnvelope>) -> Result<()> { for (type_id, key) in entries.keys() { self.refuse_second_global(entries, type_id, key)?; @@ -3742,13 +3835,20 @@ fn require_unique_keys(envelopes: &[CultCacheEnvelope]) -> Result<()> { Ok(()) } -/// What a record's stored schema id is: the id it carries, else its type (a registered type's id is its entry type). This -/// runtime's registered types declare no compatible ids, so a written record owns a held one exactly when the ids are equal: -/// the question C# `WrittenOwns` asks of the stored id, not a comparison of type names. +/// What a record's stored schema id is: the id it carries, else its type (a registered type's id is its entry type). fn schema_identity(entry: &CultCacheEnvelope) -> &str { entry.schema_id.as_deref().unwrap_or(&entry.r#type) } +/// The refusal of a write at a key a store carries as a record no registered type claims. +fn foreign_conflict(carried: &CultCacheEnvelope, written_type: &str) -> anyhow::Error { + anyhow::Error::new(SchemaConflictError { + schema_id: schema_identity(carried).to_string(), + schema_names: vec![carried.r#type.clone(), written_type.to_string()], + record_key: carried.key.clone(), + }) +} + /// The refusal of a write that would retype a key: the written record does not own the schema id the held one is stored under. /// Retyping is remove-then-add (variants:ruling:cache-refuses-retype). fn retype_conflict(held: &CultCacheEnvelope, written: &CultCacheEnvelope) -> anyhow::Error { @@ -4309,6 +4409,347 @@ mod tests { SingleFileMessagePackBackingStore::new(path).pull_all() } + const SECRET: &str = "payload-text-a-refusal-must-not-echo"; + + fn store_with(path: &Path, records: impl FnOnce(&mut CultCache) -> Result<()>) -> Result<()> { + let mut cache = CultCache::new(); + cache.register_registry(TestEntries)?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(path))?; + records(&mut cache) + } + + #[test] + fn retype_memory_cache_refuses_a_second_type_on_every_write_path_and_keeps_the_first() -> Result<()> { + let mut cache = CultCache::new(); + cache.register_registry(TestEntries)?; + cache.put("shared", &settings_one())?; + let before = cache.snapshot(); + let secret = Note { title: SECRET.to_string(), body: SECRET.to_string() }; + let (staged, _) = cache.prepare_entry("shared", &secret)?; + + let mut errors = vec![ + cache.put("shared", &secret).unwrap_err(), + cache.put_envelope::(staged.clone()).unwrap_err(), + cache.put_raw_envelope(staged.clone()).unwrap_err(), + cache.put_prepared_batch(vec![staged.clone()]).unwrap_err(), + ]; + // Two types staged at one key in one batch never land, even at a fresh key. + let (fresh_note, _) = cache.prepare_entry("fresh", &secret)?; + let (fresh_settings, _) = cache.prepare_entry("fresh", &settings_one())?; + errors.push(cache.put_prepared_batch(vec![fresh_note, fresh_settings]).unwrap_err()); + for (error, key) in errors.iter().zip(["shared", "shared", "shared", "shared", "fresh"]) { + assert_eq!(retype_refusal(error).record_key, key); + assert!(!format!("{error:#}").contains(SECRET), "a refusal never echoes a value: {error:#}"); + } + assert_eq!(cache.snapshot(), before); + cache.delete::("shared")?; + cache.put("shared", &secret)?; + Ok(()) + } + + /// Two types whose envelopes carry one schema id (an id naming no registered type, so admission passes it) are still two + /// types: ownership is the written type's own id, never the id an envelope claims (C# `WrittenOwns` reads the written + /// entry's catalog entry). Refused at a key the other type holds on every envelope path, with and without a store, and in + /// an SoA image. + #[test] + fn retype_envelopes_sharing_one_claimed_id_are_still_two_types() -> Result<()> { + let temp = tempfile::tempdir()?; + let path = temp.path().join("cache.cc"); + for stored in [false, true] { + let mut cache = CultCache::new(); + cache.register_registry(TestEntries)?; + if stored { + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path))?; + cache.pull_all_backing_stores()?; + } + let (mut settings, _) = cache.prepare_entry("shared", &settings_one())?; + settings.schema_id = Some("dynamic.shared.v1".to_string()); + cache.put_raw_envelope(settings.clone())?; + let before = (cache.snapshot(), if stored { fs::read(&path)? } else { Vec::new() }); + let (mut stamped, _) = cache.prepare_entry("shared", ¬e_one())?; + stamped.schema_id = settings.schema_id.clone(); + for error in [ + cache.put_envelope::(stamped.clone()).unwrap_err(), + cache.put_raw_envelope(stamped.clone()).unwrap_err(), + cache.put_prepared_batch(vec![stamped.clone()]).unwrap_err(), + ] { + assert_eq!(retype_refusal(&error).record_key, "shared", "stored {stored}"); + } + assert_eq!((cache.snapshot(), if stored { fs::read(&path)? } else { Vec::new() }), before, "stored {stored}"); + + let error = cache.load_soa(CultCacheSoaTable::from_envelopes(vec![settings, stamped])).unwrap_err(); + assert_eq!(retype_refusal(&error).record_key, "shared"); + assert_eq!(cache.snapshot(), before.0); + } + Ok(()) + } + + /// An envelope whose schema id names another registered type is refused at admission, whether or not this cache holds the + /// key: a stale cache (another writer put Settings at the key after this pull) and a fresh key both, on every envelope path + /// and in an SoA image, the store unchanged. The pull resolves a record's type from that id, so admitting it would store a + /// Note that every reload serves as Settings. + #[test] + fn retype_an_envelope_claiming_another_registered_types_id_is_refused_at_admission() -> Result<()> { + let temp = tempfile::tempdir()?; + let path = temp.path().join("cache.cc"); + let open = || -> Result { + let mut cache = CultCache::new(); + cache.register_registry(TestEntries)?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path))?; + cache.pull_all_backing_stores()?; + Ok(cache) + }; + let mut stale = open()?; + open()?.put("held-elsewhere", &settings_one())?; + let before = fs::read(&path)?; + for key in ["held-elsewhere", "fresh"] { + let (mut stamped, _) = stale.prepare_entry(key, ¬e_one())?; + stamped.schema_id = Some(Settings::TYPE.to_string()); + for error in [ + stale.put_envelope::(stamped.clone()).unwrap_err(), + stale.put_raw_envelope(stamped.clone()).unwrap_err(), + stale.put_prepared_batch(vec![stamped.clone()]).unwrap_err(), + stale.load_soa(CultCacheSoaTable::from_envelopes(vec![stamped.clone()])).unwrap_err(), + ] { + let refusal = retype_refusal(&error); + assert_eq!((refusal.record_key.as_str(), refusal.schema_id.as_str()), (key, Settings::TYPE), "{error:#}"); + } + } + assert!(stale.snapshot().is_empty()); + assert_eq!(fs::read(&path)?, before, "nothing reached the store"); + assert_eq!(open()?.get_required::("held-elsewhere")?, settings_one()); + Ok(()) + } + + /// A store that holds two records at one key, one stamped with the other's schema id, is refused at the pull: both resolve + /// to one type, and the cache would otherwise keep whichever came last. + #[test] + fn retype_a_store_holding_two_records_at_one_key_refuses_the_pull() -> Result<()> { + let temp = tempfile::tempdir()?; + let path = temp.path().join("cache.redb"); + let mut cache = CultCache::new(); + cache.register_registry(TestEntries)?; + let (settings, _) = cache.prepare_entry("shared", &settings_one())?; + let (mut stamped, _) = cache.prepare_entry("shared", ¬e_one())?; + stamped.schema_id = Some(Settings::TYPE.to_string()); + { + let mut store = RedbMessagePackBackingStore::new(&path)?; + store.push(&settings)?; + store.push(&stamped)?; + } + cache.add_generic_backing_store(RedbMessagePackBackingStore::new(&path)?)?; + let error = cache.pull_all_backing_stores().unwrap_err(); + assert_eq!(retype_refusal(&error).record_key, "shared"); + assert!(cache.snapshot().is_empty()); + Ok(()) + } + + #[test] + fn retype_two_stores_holding_one_key_refuse_the_pull_and_admit_nothing() -> Result<()> { + let temp = tempfile::tempdir()?; + let (first, second) = (temp.path().join("first.cc"), temp.path().join("second.cc")); + store_with(&first, |cache| cache.put("k", &settings_one()).map(drop))?; + store_with(&second, |cache| cache.put("k", ¬e_one()).map(drop))?; + let (first_bytes, second_bytes) = (fs::read(&first)?, fs::read(&second)?); + + let mut cache = CultCache::new(); + cache.register_registry(TestEntries)?; + cache.add_backing_store(SingleFileMessagePackBackingStore::new(&first), [Settings::TYPE])?; + cache.pull_all_backing_stores()?; + let before = cache.snapshot(); + cache.add_backing_store(SingleFileMessagePackBackingStore::new(&second), [Note::TYPE])?; + let error = cache.pull_all_backing_stores().unwrap_err(); + assert_eq!(retype_refusal(&error).record_key, "k"); + assert_eq!(cache.snapshot(), before, "a refused pull admits nothing"); + assert_eq!((fs::read(&first)?, fs::read(&second)?), (first_bytes, second_bytes)); + Ok(()) + } + + #[test] + fn retype_a_store_with_an_unregistered_type_loads_lists_it_and_keeps_its_bytes() -> Result<()> { + let temp = tempfile::tempdir()?; + let path = temp.path().join("cache.cc"); + store_with(&path, |cache| { + cache.put("held", &settings_one())?; + cache.put("carried", ¬e_one()).map(drop) + })?; + let carried = |path: &Path| -> Result { + Ok(reloaded(path)?.into_iter().find(|entry| entry.key == "carried").unwrap()) + }; + let original = carried(&path)?; + + for as_batch in [false, true] { + let mut cache = CultCache::new(); + cache.register_entry_type::()?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path))?; + cache.pull_all_backing_stores()?; + let foreign = cache.foreign_records(); + assert_eq!(foreign.len(), 1); + assert_eq!((foreign[0].key.as_str(), foreign[0].schema_id.as_str(), foreign[0].schema_name.as_str()), ("carried", Note::TYPE, Note::TYPE)); + assert_eq!(cache.snapshot().len(), 1, "a foreign record is listed, not served"); + + if as_batch { + let (staged, _) = cache.prepare_entry("other", &settings_one())?; + cache.put_prepared_batch(vec![staged])?; + } else { + cache.put("other", &settings_one())?; + } + assert!(cache.delete::("other")?); + assert_eq!(carried(&path)?, original, "the foreign record keeps its bytes (batch {as_batch})"); + + let secret = Settings { theme: SECRET.to_string(), retries: 9 }; + let before = fs::read(&path)?; + let mut refusals = vec![cache.put("carried", &secret).unwrap_err(), cache.delete::("carried").unwrap_err()]; + let (staged, _) = cache.prepare_entry("carried", &secret)?; + refusals.push(cache.put_prepared_batch(vec![staged]).unwrap_err()); + for error in &refusals { + assert_eq!(retype_refusal(error).record_key, "carried"); + assert!(!format!("{error:#}").contains(SECRET), "{error:#}"); + } + assert_eq!(fs::read(&path)?, before); + } + Ok(()) + } + + #[test] + fn retype_a_foreign_key_in_another_store_refuses_the_pull_and_every_write_to_it() -> Result<()> { + let temp = tempfile::tempdir()?; + let (routed, generic) = (temp.path().join("routed.cc"), temp.path().join("generic.cc")); + store_with(&routed, |cache| cache.put("k", &settings_one()).map(drop))?; + store_with(&generic, |cache| cache.put("n", ¬e_one()).map(drop))?; + let open = || -> Result { + let mut cache = CultCache::new(); + cache.register_entry_type::()?; + cache.add_backing_store(SingleFileMessagePackBackingStore::new(&routed), [Settings::TYPE])?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&generic))?; + Ok(cache) + }; + + // A registered record in one store and a foreign one at the same key in another: the pull is refused. + store_with(&generic, |cache| cache.put("k", ¬e_one()).map(drop))?; + let mut cache = open()?; + assert_eq!(retype_refusal(&cache.pull_all_backing_stores().unwrap_err()).record_key, "k"); + assert!(cache.snapshot().is_empty() && cache.foreign_records().is_empty()); + + // The foreign key lives in the generic store; a write routed to the other store is refused by the cache alone. + fs::remove_file(&generic)?; + store_with(&generic, |cache| cache.put("n", ¬e_one()).map(drop))?; + let mut cache = open()?; + cache.pull_all_backing_stores()?; + let before = (cache.snapshot(), fs::read(&routed)?, fs::read(&generic)?); + let (staged, _) = cache.prepare_entry("n", &settings_one())?; + for error in [cache.put("n", &settings_one()).unwrap_err(), cache.put_prepared_batch(vec![staged]).unwrap_err()] { + assert_eq!(retype_refusal(&error).record_key, "n"); + } + assert_eq!((cache.snapshot(), fs::read(&routed)?, fs::read(&generic)?), before); + Ok(()) + } + + /// A batch lays back only its home store's foreign records: another store's are never copied into it. + #[test] + fn retype_a_batch_lays_back_only_its_own_stores_foreign_records() -> Result<()> { + let temp = tempfile::tempdir()?; + let (routed, generic) = (temp.path().join("routed.cc"), temp.path().join("generic.cc")); + store_with(&routed, |cache| cache.put("own", &settings_one()).map(drop))?; + store_with(&generic, |cache| cache.put("carried", ¬e_one()).map(drop))?; + let generic_bytes = fs::read(&generic)?; + let mut cache = CultCache::new(); + cache.register_entry_type::()?; + cache.add_backing_store(SingleFileMessagePackBackingStore::new(&routed), [Settings::TYPE])?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&generic))?; + cache.pull_all_backing_stores()?; + let (staged, _) = cache.prepare_entry("other", &settings_one())?; + cache.put_prepared_batch(vec![staged])?; + let keys: Vec = reloaded(&routed)?.into_iter().map(|entry| entry.key).collect(); + assert_eq!(keys, ["other", "own"], "the routed store holds its own records only"); + assert_eq!(fs::read(&generic)?, generic_bytes); + cache.pull_all_backing_stores()?; + Ok(()) + } + + #[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] + #[cultcache(type = "only-one", schema = "tests.only_one", global)] + struct OnlyOne { + #[cultcache(key = 0)] + text: String, + } + + /// A pull refused after every record is read (here, a second global) changes nothing, the foreign list included. + #[test] + fn retype_a_pull_refused_after_reading_keeps_the_foreign_list() -> Result<()> { + let temp = tempfile::tempdir()?; + let path = temp.path().join("cache.cc"); + store_with(&path, |cache| cache.put("carried", ¬e_one()).map(drop))?; + let mut cache = CultCache::new(); + cache.register_entry_type::()?; + cache.register_entry_type::()?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path))?; + cache.pull_all_backing_stores()?; + let before = (cache.snapshot(), cache.foreign_records()); + + // Behind the cache's back: another foreign record and two records of the global type. + let mut store = SingleFileMessagePackBackingStore::new(&path); + let (mut late, _) = cache.prepare_entry("late", &settings_one())?; + late.r#type = Note::TYPE.to_string(); + late.schema_id = Some(Note::TYPE.to_string()); + store.push(&late)?; + for key in ["g1", "g2"] { + store.push(&cache.prepare_entry(key, &OnlyOne { text: key.to_string() })?.0)?; + } + assert!(cache.pull_all_backing_stores().is_err()); + assert_eq!((cache.snapshot(), cache.foreign_records()), before, "a refused pull changes nothing"); + Ok(()) + } + + /// An SoA image is judged against the foreign keys too: it may not put a record at a key a store carries foreign. + #[test] + fn retype_load_soa_refuses_a_record_at_a_foreign_key() -> Result<()> { + let temp = tempfile::tempdir()?; + let path = temp.path().join("cache.cc"); + store_with(&path, |cache| cache.put("carried", ¬e_one()).map(drop))?; + let mut cache = CultCache::new(); + cache.register_entry_type::()?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path))?; + cache.pull_all_backing_stores()?; + let (staged, _) = cache.prepare_entry("carried", &settings_one())?; + let error = cache.load_soa(CultCacheSoaTable::from_envelopes(vec![staged])).unwrap_err(); + assert_eq!(retype_refusal(&error).record_key, "carried"); + assert!(cache.snapshot().is_empty()); + Ok(()) + } + + #[test] + fn retype_load_soa_refuses_a_second_type_at_a_key_and_admits_nothing() -> Result<()> { + let mut cache = CultCache::new(); + cache.register_registry(TestEntries)?; + cache.put("kept", &settings_one())?; + let before = cache.snapshot(); + let (settings, _) = cache.prepare_entry("k", &settings_one())?; + let (note, _) = cache.prepare_entry("k", ¬e_one())?; + let error = cache.load_soa(CultCacheSoaTable::from_envelopes(vec![settings, note])).unwrap_err(); + assert_eq!(retype_refusal(&error).record_key, "k"); + assert_eq!(cache.snapshot(), before); + Ok(()) + } + + #[test] + fn retype_a_later_registration_claims_the_foreign_record_at_the_next_load() -> Result<()> { + let temp = tempfile::tempdir()?; + let path = temp.path().join("cache.cc"); + store_with(&path, |cache| cache.put("carried", ¬e_one()).map(drop))?; + + let mut cache = CultCache::new(); + cache.register_entry_type::()?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path))?; + cache.pull_all_backing_stores()?; + assert_eq!(cache.foreign_records().len(), 1); + cache.register_entry_type::()?; + cache.pull_all_backing_stores()?; + assert!(cache.foreign_records().is_empty()); + assert_eq!(cache.get_required::("carried")?, note_one()); + Ok(()) + } + #[test] fn a_second_type_under_a_stored_key_is_refused_and_the_store_and_cache_stay() -> Result<()> { let temp = tempfile::tempdir()?; @@ -5321,24 +5762,26 @@ mod tests { #[test] fn refused_pull_leaves_prior_view_intact() -> Result<()> { let temp = tempfile::tempdir()?; - let store_path = temp.path().join("generic.cc"); + let (settings_path, generic_path) = (temp.path().join("settings.cc"), temp.path().join("generic.cc")); let mut cache = CultCache::new(); cache.register_entry_type::()?; - cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; + cache.add_backing_store(SingleFileMessagePackBackingStore::new(&settings_path), [Settings::TYPE])?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&generic_path))?; cache.put("app", &Settings { theme: "before".to_string(), retries: 1 })?; let before = cache.snapshot(); - // A record of a type this cache never registered lands in the store behind its back. - SingleFileMessagePackBackingStore::new(&store_path).push(&CultCacheEnvelope { - key: "stray".into(), + // A record of a type this cache never registered lands, behind its back, in the other store at a held key. + SingleFileMessagePackBackingStore::new(&generic_path).push(&CultCacheEnvelope { + key: "app".into(), r#type: "unregistered".into(), payload: rmp_serde::to_vec(&("x",))?, stored_at: "2026-09-14T00:00:00Z".into(), schema_id: Some("unregistered".into()), })?; let error = cache.pull_all_backing_stores().unwrap_err(); - assert!(error.to_string().contains("\"unregistered\""), "{error}"); + assert_eq!(retype_refusal(&error).record_key, "app"); assert_eq!(cache.snapshot(), before); + assert!(cache.foreign_records().is_empty()); assert_eq!(cache.get_required::("app")?.theme, "before"); Ok(()) } @@ -5466,30 +5909,6 @@ mod tests { Ok(()) } - #[test] - fn pull_rejects_unregistered_persisted_entry_type() -> Result<()> { - let temp = tempfile::tempdir()?; - let store_path = temp.path().join("cache.cc"); - let mut store = SingleFileMessagePackBackingStore::new(&store_path); - store.push(&CultCacheEnvelope { - key: "unknown".to_string(), - r#type: "unregistered".to_string(), - payload: rmp_serde::to_vec(&1_u8)?, - stored_at: now_utc_second(), - schema_id: Some("unregistered".to_string()), - })?; - - let mut cache = CultCache::new(); - cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; - let error = cache.pull_all_backing_stores().unwrap_err(); - assert!( - error - .to_string() - .contains("No schema is registered for persisted entry type") - ); - Ok(()) - } - #[test] fn payload_is_binary_messagepack_not_json_value() -> Result<()> { let temp = tempfile::tempdir()?; diff --git a/packages/cultcache-rs/tests/key_identity_vector.rs b/packages/cultcache-rs/tests/key_identity_vector.rs new file mode 100644 index 00000000..13cc8de5 --- /dev/null +++ b/packages/cultcache-rs/tests/key_identity_vector.rs @@ -0,0 +1,283 @@ +//! `tests/vectors/key-identity`, written only by the C# reference: a cache holds one record per key whatever its type, and a +//! store holding a record no registered type claims loads, lists it as foreign and keeps its bytes +//! (`variants:ruling:cache-refuses-retype`, `write-ownership-by-written-type`, `unheld-removal-refused-at-commit`). +//! Every row is walked against this cache: each put row twice, once as `put` and once as a one-entry `put_prepared_batch`. + +use std::path::{Path, PathBuf}; + +use cultcache_rs::{ + CacheBackingStore, CultCache, DatabaseEntry, SchemaConflictError, SingleFileMessagePackBackingStore, +}; +use serde_json::Value; + +// The ids C# writes in the store catalogs and on every record (the vector's README). +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "sha256:b20703a98f49c253898404ab50ba2c630720f0b8a5e1a41614d5650be22799fe", schema = "tests.ki_alpha")] +struct Alpha { + #[cultcache(key = 0)] + text: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "sha256:dee4b727cf2ee04e61ab635ff0c5f3dca7345a411dc5f1018e583ab16009cf94", schema = "tests.ki_beta")] +struct Beta { + #[cultcache(key = 0)] + text: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "sha256:fbc26e351e00c9e7185e8de171c7486aba707c229c949f0aae6b889288af5f6d", schema = "tests.ki_gamma")] +struct Gamma { + #[cultcache(key = 0)] + text: String, +} + +fn vectors() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../tests/vectors/key-identity") +} + +/// `[refusal key or nil, held [type, key, text], file [key, schema name] or nil, foreign keys, kept foreign keys]`. +type Expect = (Option>, Vec<(String, String, String)>, Option>, Vec, Vec); +type Row = (String, Vec, Vec>, Expect); + +fn text(value: &Value) -> &str { + value.as_str().unwrap() +} + +fn register(cache: &mut CultCache, names: &[String]) { + for name in names { + match name.as_str() { + "alpha" => drop(cache.register_entry_type::().unwrap()), + "beta" => drop(cache.register_entry_type::().unwrap()), + "gamma" => drop(cache.register_entry_type::().unwrap()), + other => panic!("unknown type {other}"), + } + } +} + +fn short(entry_type: &str) -> &'static str { + match entry_type { + t if t == Alpha::TYPE => "alpha", + t if t == Beta::TYPE => "beta", + t if t == Gamma::TYPE => "gamma", + other => panic!("unknown entry type {other}"), + } +} + +fn put(cache: &mut CultCache, as_batch: bool, kind: &str, key: &str, body: &str) -> anyhow::Result<()> { + macro_rules! write { + ($t:ident) => {{ + let value = $t { text: body.to_string() }; + if as_batch { + let (entry, _) = cache.prepare_entry(key, &value)?; + cache.put_prepared_batch(vec![entry]) + } else { + cache.put(key, &value).map(drop) + } + }}; + } + match kind { + "alpha" => write!(Alpha), + "beta" => write!(Beta), + "gamma" => write!(Gamma), + other => panic!("unknown type {other}"), + } +} + +fn delete(cache: &mut CultCache, kind: &str, key: &str) -> anyhow::Result { + match kind { + "alpha" => cache.delete::(key), + "beta" => cache.delete::(key), + "gamma" => cache.delete::(key), + other => panic!("unknown type {other}"), + } +} + +fn open_cache(registered: &[String], path: Option<&Path>) -> anyhow::Result { + let mut cache = CultCache::new(); + register(&mut cache, registered); + if let Some(path) = path { + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(path))?; + cache.pull_all_backing_stores()?; + } + Ok(cache) +} + +/// The records a store file holds, read by a fresh store: (key, schema name, schema id, stored-at, payload). +fn file_records(path: &Path) -> Vec<(String, String, Option, String, Vec)> { + let mut records: Vec<_> = SingleFileMessagePackBackingStore::new(path) + .pull_all() + .unwrap() + .into_iter() + .map(|entry| (entry.key, entry.r#type, entry.schema_id, entry.stored_at, entry.payload)) + .collect(); + records.sort_by(|left, right| left.0.encode_utf16().cmp(right.0.encode_utf16())); + records +} + +fn walk(row: &Row, as_batch: bool) -> Result<(), String> { + let (label, registered, ops, expect) = row; + let dir = tempfile::tempdir().unwrap(); + let mut path: Option = None; + let mut before = Vec::new(); + let mut cache: Option = None; + let mut refused: Option = None; + let mut refused_key: Option = None; + let mut shared_key: Option = None; + let mut wrote = false; + for (index, op) in ops.iter().enumerate() { + let last = index == ops.len() - 1; + let result: anyhow::Result<()> = match text(&op[0]) { + "open" => { + drop(cache.take()); + match &op[1] { + Value::String(file) if file == "memory" => open_cache(registered, None).map(|opened| cache = Some(opened)), + file => { + let fresh = dir.path().join("store.cc"); + if let Value::String(file) = file { + std::fs::copy(vectors().join(file), &fresh).unwrap(); + } + before = if fresh.exists() { file_records(&fresh) } else { Vec::new() }; + let opened = open_cache(registered, Some(&fresh)); + path = Some(fresh); + opened.map(|opened| cache = Some(opened)) + } + } + } + "reopen" => { + drop(cache.take()); + open_cache(registered, path.as_deref()).map(|opened| cache = Some(opened)) + } + "attach" => { + let file = text(&op[1]); + let attached = dir.path().join(file); + std::fs::copy(vectors().join(file), &attached).unwrap(); + let held: Vec = cache.as_ref().unwrap().snapshot().into_iter().map(|entry| entry.key).collect(); + let shared: Vec = file_records(&attached).into_iter().map(|record| record.0).filter(|key| held.contains(key)).collect(); + let home: Vec = op[2].as_array().unwrap().iter().map(|name| short_to_type(text(name)).to_string()).collect(); + let cache = cache.as_mut().unwrap(); + let attach = cache + .add_backing_store(SingleFileMessagePackBackingStore::new(&attached), home) + .and_then(|()| cache.pull_all_backing_stores()); + if attach.is_err() && last && shared.len() == 1 { + refused_key = Some(shared[0].clone()); + shared_key = refused_key.clone(); + assert_eq!(std::fs::read(&attached).unwrap(), std::fs::read(vectors().join(file)).unwrap(), "{label}: a refused attach leaves the store as it was"); + } + attach + } + "put" => { + wrote = true; + put(cache.as_mut().unwrap(), as_batch, text(&op[1]), text(&op[2]), text(&op[3])) + } + "delete" => delete(cache.as_mut().unwrap(), text(&op[1]), text(&op[2])).map(drop), + other => panic!("{label}: unknown op {other}"), + }; + match result { + Ok(()) => {} + Err(error) if last => refused = Some(error), + Err(error) => return Err(format!("{label}: op {index} failed: {error:#}")), + } + } + if !wrote && as_batch { + return Ok(()); // not a write row: the batch variant is the same walk + } + let Some(cache) = cache.as_ref() else { + return Err(format!("{label} (batch {as_batch}): the cache did not open: {:#}", refused.unwrap())); + }; + let last = ops.last().unwrap(); + if let Some(error) = &refused { + if let Some(conflict) = error.downcast_ref::() { + refused_key = Some(conflict.record_key.clone()); + let named = if text(&last[0]) == "attach" { shared_key.clone().unwrap() } else { text(&last[2]).to_string() }; + assert_eq!(named, conflict.record_key, "{label}: the refusal names the key written, removed or shared"); + } + let key = refused_key.clone().unwrap_or_default(); + let message = format!("{error:#}"); + let (all, delimited) = occurrences(&message, &key); + assert!(!key.is_empty() && delimited > 0, "{label}: the refusal names the key as a token: {message}"); + if text(&last[0]) == "attach" { + assert_eq!(all, delimited, "{label}: the key occurs in the refusal only as a delimited token: {message}"); + } + } + let mut held: Vec<(String, String, String)> = cache + .snapshot() + .into_iter() + .map(|entry| { + let body = match short(&entry.r#type) { + "alpha" => cache.get::(&entry.key).unwrap().unwrap().text, + "beta" => cache.get::(&entry.key).unwrap().unwrap().text, + _ => cache.get::(&entry.key).unwrap().unwrap().text, + }; + (short(&entry.r#type).to_string(), entry.key, body) + }) + .collect(); + held.sort_by(|a, b| (a.0.as_str(), a.1.encode_utf16().collect::>()).cmp(&(b.0.as_str(), b.1.encode_utf16().collect::>()))); + let foreign: Vec = cache.foreign_records().into_iter().map(|record| record.key).collect(); + let (file, kept) = match &path { + None => (None, Vec::new()), + Some(path) => { + let after = file_records(path); + let kept = foreign + .iter() + .filter(|key| { + let original = before.iter().find(|record| &record.0 == *key); + let now = after.iter().find(|record| &record.0 == *key); + original.is_some() && original.map(|r| (&r.2, &r.3, &r.4)) == now.map(|r| (&r.2, &r.3, &r.4)) + }) + .cloned() + .collect(); + // This writer names a catalog entry by the record's type, which for these types is the id; C# names it by the + // schema name. Which type the record at a key is, is the verdict, so the id reads as its schema name. + let name = |described: String| match described.as_str() { + t if t == Alpha::TYPE => Alpha::SCHEMA_NAME.to_string(), + t if t == Beta::TYPE => Beta::SCHEMA_NAME.to_string(), + t if t == Gamma::TYPE => Gamma::SCHEMA_NAME.to_string(), + _ => described, + }; + (Some(after.into_iter().map(|record| (record.0, name(record.1))).collect::>()), kept) + } + }; + let observed: Expect = (refused_key.map(|key| vec![key]), held, file, foreign, kept); + if &observed == expect { Ok(()) } else { Err(format!("{label} (batch {as_batch}): committed {expect:?} but this cache answers {observed:?}")) } +} + +/// How often `key` occurs in `message`, and how often as a delimited token (not a piece of a longer word, schema name or id). +fn occurrences(message: &str, key: &str) -> (usize, usize) { + let word = |c: Option| c.is_some_and(|c| c.is_ascii_alphanumeric() || c == '_'); + let found: Vec = message.match_indices(key).map(|(at, _)| at).collect(); + let delimited = found + .iter() + .filter(|at| !word(message[..**at].chars().next_back()) && !word(message[**at + key.len()..].chars().next())) + .count(); + (found.len(), delimited) +} + +fn short_to_type(name: &str) -> &'static str { + match name { + "alpha" => Alpha::TYPE, + "beta" => Beta::TYPE, + "gamma" => Gamma::TYPE, + other => panic!("unknown type {other}"), + } +} + +#[test] +fn key_identity_rows_hold_their_verdicts() { + let bytes = std::fs::read(vectors().join("rows.msgpack")).unwrap(); + let rows: Vec = rmp_serde::from_slice(&bytes).unwrap(); + let mut walked = 0; + let mut failed = Vec::new(); + for row in &rows { + let mut ok = true; + for as_batch in [false, true] { + if let Err(message) = walk(row, as_batch) { + failed.push(message); + ok = false; + } + } + walked += usize::from(ok); + } + assert!(failed.is_empty(), "{}", failed.join("\n")); + assert_eq!(walked, 12, "rows walked"); +} diff --git a/packages/cultcache-rs/tests/unique_index.rs b/packages/cultcache-rs/tests/unique_index.rs index 1132b2b5..193ba6fb 100644 --- a/packages/cultcache-rs/tests/unique_index.rs +++ b/packages/cultcache-rs/tests/unique_index.rs @@ -245,9 +245,9 @@ fn delete_releases_a_value() { fn types_sharing_an_alias_and_a_value_do_not_conflict() { let mut cache = memory_cache(); cache.put("a", &unique("S")).unwrap(); - cache.put("a", &Other { sku: "S".into() }).unwrap(); - cache.put("b", &Other { sku: "T".into() }).unwrap(); - assert!(cache.put("b", &Other { sku: "S".into() }).is_err()); + cache.put("oa", &Other { sku: "S".into() }).unwrap(); + cache.put("ob", &Other { sku: "T".into() }).unwrap(); + assert!(cache.put("ob", &Other { sku: "S".into() }).is_err()); assert_eq!(cache.get_by_index::("Sku", "S").unwrap().unwrap(), unique("S")); assert_eq!(cache.get_by_index::("Sku", "S").unwrap().unwrap(), Other { sku: "S".into() }); } diff --git a/tests/vectors/key-identity/README.md b/tests/vectors/key-identity/README.md index f6ad6516..65e137b1 100644 --- a/tests/vectors/key-identity/README.md +++ b/tests/vectors/key-identity/README.md @@ -19,6 +19,9 @@ Registering a type that declares its schema id claims it at the next load. - `tests.ki_alpha`: `sha256:b20703a98f49c253898404ab50ba2c630720f0b8a5e1a41614d5650be22799fe` - `tests.ki_beta`: `sha256:dee4b727cf2ee04e61ab635ff0c5f3dca7345a411dc5f1018e583ab16009cf94` - `tests.ki_gamma`: `sha256:fbc26e351e00c9e7185e8de171c7486aba707c229c949f0aae6b889288af5f6d` + The Rust writer names a catalog entry it writes by the record's type, which for these types is the id, where C# names it by + schema name; the Rust walker (`packages/cultcache-rs/tests/key_identity_vector.rs`) maps each of the three ids, and only + those, back to its schema name before comparing `file`, so the verdict is compared and the spelling divergence is not. - `foreign-store.cc`: a single-file store, alpha `a` at key `j`, gamma `g` at key `k`, stored-at pinned to `2026-01-01T00:00:00Z`. `catalog-store.cc`: alpha `a` at `qx`. `run-store.cc`: beta `b` at `qx` (row 11). The row-11 key is `qx` because it must occur in the refusal's message only as the key, never inside a schema name (`k` is inside `ki_alpha`). From a8c2825745b46d47ef8934c18a11b5d9ff55f8bf Mon Sep 17 00:00:00 2001 From: Libby Date: Thu, 12 Nov 2026 02:45:08 +0000 Subject: [PATCH 44/93] Slot tolerance: a record with unread slots is held read-only, not shed (#124) --- .../CultDocumentMessagePackSerialization.cs | 18 +- .../CultSlotScope.cs | 47 ++ .../CultUnionGuard.cs | 115 +++- .../DirectoryMessagePackBackingStore.cs | 2 +- .../GameCult.Caching.MessagePack.csproj | 1 + .../Contracts/cultcache-persistence-format.md | 19 +- .../cultcache-schema-compatibility.md | 40 +- src/GameCult.Caching/CultCache.cs | 181 ++++- .../CultSchemaConflictException.cs | 13 +- src/GameCult.Caching/CultUnreadSlots.cs | 54 ++ .../GameCult.Caching.GeneratorConsumer.csproj | 3 + .../Program.cs | 3 +- tests/GameCult.Caching.Tests/AssemblyInfo.cs | 2 + .../SlotReadOnlyTests.cs | 639 ++++++++++++++++++ .../UnionParityVectorTests.cs | 149 +++- tests/GameCult.Caching.Tests/VariantTests.cs | 18 +- tests/vectors/union-parity/README.md | 96 ++- .../union-parity/extra-slot-consent.cc | Bin 0 -> 3479 bytes .../vectors/union-parity/extra-slot-store.cc | Bin 0 -> 3873 bytes .../union-parity/slot-outcomes.msgpack | Bin 0 -> 378 bytes unity/org.gamecult.cultlib/CHANGELOG.md | 9 + 21 files changed, 1345 insertions(+), 64 deletions(-) create mode 100644 src/GameCult.Caching.MessagePack/CultSlotScope.cs create mode 100644 src/GameCult.Caching/CultUnreadSlots.cs create mode 100644 tests/GameCult.Caching.Tests/SlotReadOnlyTests.cs create mode 100644 tests/vectors/union-parity/extra-slot-consent.cc create mode 100644 tests/vectors/union-parity/extra-slot-store.cc create mode 100644 tests/vectors/union-parity/slot-outcomes.msgpack diff --git a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs index 0acee656..15b70c9c 100644 --- a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs +++ b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs @@ -166,7 +166,7 @@ public static class CultDocumentMessagePackSerialization private static (MessagePackSerializerOptions Options, IFormatterResolver[] Ahead) Compose(IFormatterResolver[] consumerResolvers) { - // The guard goes first and wraps whatever formatter the rest of the composite would hand out, so a consumer + // The guard goes first and wraps whatever formatter the rest of the composite would hand out (a union, or a concrete int-keyed object for its slot count), so a consumer // resolver still encodes an arm while the key check runs. var ahead = consumerResolvers.Append(CultDocumentResolver.Instance).Append(CultMathResolver.Instance).Append(CultMathExprResolver.Instance).ToArray(); var inner = CompositeResolver.Create(ahead.Append(StandardResolver.Instance).ToArray()); @@ -214,6 +214,19 @@ public static class CultDocumentMessagePackSerialization ?? throw new InvalidOperationException($"MessagePack returned null for Cult document type {type.FullName}."); } + // The same decode, with the slots it read past noted (CultSlotScope). Opened around the one decode and after the depth check, + // so a payload past the nesting limit is refused, never marked. + internal static CultDecoded DeserializeMarked(Type type, byte[] payload, CultDocumentRegistry registry) + { + if (registry == null) throw new ArgumentNullException(nameof(registry)); + registry.GetRequired(type); + CultPayloadDepth.Check(payload, type, PayloadDirection.Read); + using var scope = CultSlotScope.Open(); + var document = MessagePackSerializer.Deserialize(type, payload, OptionsForDocument(type)) + ?? throw new InvalidOperationException($"MessagePack returned null for Cult document type {type.FullName}."); + return new CultDecoded(document, scope.Finding()); + } + /// /// Replaces slots of a serialized document payload (a MessagePack array indexed by slot) with already-encoded member /// values, leaving every other slot byte for byte as it was. A slot past the end extends the array with nil. @@ -823,4 +836,7 @@ public class SingleFileMessagePackBackingStore : SingleFileBackingStore { return CultDocumentMessagePackSerialization.DeserializeUntyped(documentType, payload, Registry); } + + internal override CultDecoded DeserializeMarked(Type documentType, byte[] payload) => + CultDocumentMessagePackSerialization.DeserializeMarked(documentType, payload, Registry); } diff --git a/src/GameCult.Caching.MessagePack/CultSlotScope.cs b/src/GameCult.Caching.MessagePack/CultSlotScope.cs new file mode 100644 index 00000000..64d5b6c6 --- /dev/null +++ b/src/GameCult.Caching.MessagePack/CultSlotScope.cs @@ -0,0 +1,47 @@ +using System; +using GameCult.Caching; + +namespace GameCult.Caching.MessagePack; + +/// +/// The marks one payload decode noted: the guard's slot formatter reports every object whose array held more slots than its type +/// declares, with the nesting depth it sat at, and DeserializeMarked reads the finding out. Thread-bound and open only around the one +/// decode, so a decode outside it (a CultMesh read, a codec) notes nothing. +/// +internal sealed class CultSlotScope : IDisposable +{ + [ThreadStatic] private static CultSlotScope? current; + + private readonly CultSlotScope? previous; + private string? typeName; + private int slotCount; + private int declaredCount; + private int occurrences; + private bool allAtDepthZero = true; + + private CultSlotScope() => previous = current; + + internal static CultSlotScope? Current => current; + + internal static CultSlotScope Open() => current = new CultSlotScope(); + + // 0 for the document's own object; each object nested inside it adds one (a union adds none, its arm is the object). + internal int Depth { get; set; } + + internal void Note(string name, int slots, int declared) + { + if (occurrences++ == 0) + { + typeName = name; + slotCount = slots; + declaredCount = declared; + } + + if (Depth > 0) allAtDepthZero = false; + } + + internal CultUnreadSlots? Finding() => + occurrences == 0 ? null : new CultUnreadSlots(typeName!, slotCount, declaredCount, occurrences, allAtDepthZero, explained: false); + + public void Dispose() => current = previous; +} diff --git a/src/GameCult.Caching.MessagePack/CultUnionGuard.cs b/src/GameCult.Caching.MessagePack/CultUnionGuard.cs index 2b60c80b..86d26624 100644 --- a/src/GameCult.Caching.MessagePack/CultUnionGuard.cs +++ b/src/GameCult.Caching.MessagePack/CultUnionGuard.cs @@ -1,14 +1,17 @@ using System; +using System.Buffers; using System.Collections.Generic; using System.Linq; using System.Reflection; +using System.Runtime.CompilerServices; using MessagePack; using MessagePack.Formatters; +using MessagePack.Resolvers; namespace GameCult.Caching.MessagePack; /// -/// The one place an unknown union arm or an unnamed subtype is decided. A union is an abstract class or interface carrying +/// The one sensor of an unknown union arm, an unnamed subtype and a slot past the reader's declared slots. A union is an abstract class or interface carrying /// its own [Union] attributes (never inherited: a union whose arms are also a union has its own key set). MessagePack-CSharp /// still encodes the [key, armSlots] shape; this wraps its formatter so that a key the union does not declare is refused on /// read, and a value whose runtime type is not a declared arm is refused on write, where MessagePack alone yields null and nil. @@ -18,11 +21,117 @@ internal sealed class CultUnionGuard(IFormatterResolver inner) : IFormatterResol public IMessagePackFormatter? GetFormatter() { var arms = typeof(T).GetCustomAttributes(inherit: false).ToArray(); - return arms.Length == 0 || inner.GetFormatter() is not { } formatter ? null : new Formatter(formatter, arms); + if (arms.Length == 0) + return inner.GetFormatter() is { } objectFormatter && KeyLayout(objectFormatter) ? new SlotFormatter(objectFormatter) : null; + return inner.GetFormatter() is not { } formatter ? null : new Formatter(formatter, arms); } - private sealed class Formatter(IMessagePackFormatter inner, UnionAttribute[] arms) : IMessagePackFormatter + // The formatter that writes and reads the bytes: the one a guard wrapper delegates to, else the formatter itself. A check that a + // generated formatter is the one in use asks this (tests/GameCult.Caching.GeneratorConsumer). + internal static object Inner(object formatter) => formatter is IWrapper wrapper ? wrapper.Inner : formatter; + + private interface IWrapper { + object Inner { get; } + } + + // The one decision of whether a type is measured: the formatter the chain hands out for an int-keyed [MessagePackObject] type is + // MessagePack's own [Key]-layout formatter, the very instance StandardResolver (or its AllowPrivate twin) builds for it. A formatter a + // consumer resolver or a [MessagePackFormatter] attribute supplies owns its layout and its tolerance, and a [MessagePackObject] + // collection is written as its items by the generic resolver: none of those is measured. Identity, as ResolverClaim asks it. + private static bool KeyLayout(IMessagePackFormatter formatter) + { + if (typeof(T).IsAbstract || typeof(T).IsInterface || + typeof(T).GetCustomAttribute(inherit: true) is not { KeyAsPropertyName: false }) + return false; + if (ReferenceEquals(formatter, Of(AttributeFormatterResolver.Instance)) || ReferenceEquals(formatter, Of(DynamicGenericResolver.Instance))) + return false; + return ReferenceEquals(formatter, Of(StandardResolver.Instance)) || ReferenceEquals(formatter, Of(StandardResolverAllowPrivate.Instance)); + } + + private static IMessagePackFormatter? Of(IFormatterResolver resolver) + { + try + { + return resolver.GetFormatter(); + } + catch (Exception) + { + return null; + } + } + + // Counts a concrete object's array header against its declared slots and notes the excess into the open CultSlotScope, then lets the + // inner formatter read it unchanged: no byte scan, no second decode. With no scope open it is a depth-free pass-through. + // Declared slots are what the type's own formatter writes: the array header of one instance written through it, measured on the + // first read inside a scope (not in GetFormatter, so a self-referential type never recurses into its own resolver cache) and kept. + private sealed class SlotFormatter(IMessagePackFormatter inner) : IMessagePackFormatter, IWrapper + { + object IWrapper.Inner => inner; + + private const int NotMeasured = -1; + + // A type no instance of which can be written is not guarded: no count can exceed this. + private const int Unguarded = int.MaxValue; + + private int declared = NotMeasured; + + public void Serialize(ref MessagePackWriter writer, T value, MessagePackSerializerOptions options) => + inner.Serialize(ref writer, value, options); + + public T Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + { + var scope = CultSlotScope.Current; + if (scope == null) return inner.Deserialize(ref reader, options); + var peek = reader.CreatePeekReader(); + if (!peek.TryReadNil() && peek.NextMessagePackType == MessagePackType.Array) + { + var count = peek.ReadArrayHeader(); + if (declared == NotMeasured) declared = Measure(options); + if (count > declared) scope.Note(typeof(T).Name, count, declared); + } + + scope.Depth++; + try + { + return inner.Deserialize(ref reader, options); + } + finally + { + scope.Depth--; + } + } + + // The uninitialized instance, else one from a parameterless constructor (public or not) when that write throws. + private int Measure(MessagePackSerializerOptions options) + { + if (Written(() => (T)RuntimeHelpers.GetUninitializedObject(typeof(T)), options) is { } slots) return slots; + var constructor = typeof(T).GetConstructor(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, null, Type.EmptyTypes, null); + return constructor != null && Written(() => (T)constructor.Invoke(null), options) is { } constructed ? constructed : Unguarded; + } + + private int? Written(Func instance, MessagePackSerializerOptions options) + { + try + { + var buffer = new ArrayBufferWriter(); + var writer = new MessagePackWriter(buffer); + inner.Serialize(ref writer, instance(), options); + writer.Flush(); + var written = new MessagePackReader(buffer.WrittenMemory); + return written.NextMessagePackType == MessagePackType.Array ? written.ReadArrayHeader() : null; + } + catch (Exception) + { + return null; + } + } + } + + private sealed class Formatter(IMessagePackFormatter inner, UnionAttribute[] arms) : IMessagePackFormatter, IWrapper + { + object IWrapper.Inner => inner; + private readonly HashSet keys = arms.Select(arm => arm.Key).ToHashSet(); private readonly HashSet types = arms.Select(arm => arm.SubType).ToHashSet(); diff --git a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs index a113db44..8f76cbae 100644 --- a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs +++ b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs @@ -636,7 +636,7 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore record, VersionOf(metadata.Payload), catalog, - (type, payload) => CultDocumentMessagePackSerialization.DeserializeUntyped(type, payload, Registry), + (type, payload) => CultDocumentMessagePackSerialization.DeserializeMarked(type, payload, Registry), out report); // The manifest is this store's record of what is on disk: the stored id and the version a commit condition compares with. stored.StoredSchemaId = metadata.SchemaId; diff --git a/src/GameCult.Caching.MessagePack/GameCult.Caching.MessagePack.csproj b/src/GameCult.Caching.MessagePack/GameCult.Caching.MessagePack.csproj index 6261ca3d..63cbf614 100644 --- a/src/GameCult.Caching.MessagePack/GameCult.Caching.MessagePack.csproj +++ b/src/GameCult.Caching.MessagePack/GameCult.Caching.MessagePack.csproj @@ -11,6 +11,7 @@ + diff --git a/src/GameCult.Caching/Contracts/cultcache-persistence-format.md b/src/GameCult.Caching/Contracts/cultcache-persistence-format.md index 93faf63f..5ac6c3b5 100644 --- a/src/GameCult.Caching/Contracts/cultcache-persistence-format.md +++ b/src/GameCult.Caching/Contracts/cultcache-persistence-format.md @@ -202,6 +202,20 @@ flatten, remove, commit) is refused with the typed schema conflict before anythi Declaring its id compatible on any registered type makes it writable and is the consent to the loss: the next write stores it under the writing type's own id and sheds the slots. +A record whose payload carries a slot past the slots its reader declares, at any depth (nested object, list +element, union arm, unit arm), is read-only the same way, whatever id it is stored under. The one consent +is the declaration above, and it reaches only depth-0 slots the schema comparison reports ignored under a +listed compatible id, up to the highest slot it reports: a mark anywhere else, past that slot, or at depth 0 +under the reader's own id, is not consented to. Declared slots are what the type's own formatter writes; only +a type MessagePack writes by its `[Key]` layout is checked, and one no instance of which can be written is not +(`cultlib-gaps:ruling:slot-unmeasurable-type`; see the schema-compatibility contract for both exemptions and +for the user code the measure runs). +A flatten of a variant whose base chain holds a record that is not owned is refused with that record's +refusal, because it would write the base's decoded view; the variant's own writes persist only its delta and +stay allowed. A plain write to another key of a value read from a read-only record is a copy this rule does +not police. `CultCache.IsWritable(key)` answers what the staging refusal answers for a removal or a write of +the record's type, read-only and missing home stores included; it does not predict that flatten. + The rule is checked again where the write lands, under the store's lock: a write or removal at a key whose durable record is not owned, because another writer rewrote it after this cache loaded, is refused typed, and the record is reloaded as the store holds it, read-only when a type can @@ -535,15 +549,14 @@ Read directly when: Allowed when: - persisted schema has missing slots that the local schema can default -- persisted schema has extra slots the local reader can safely ignore or preserve - slot meanings remain compatible -Soft migration should emit warnings, not silent shrugs. +A persisted schema with extra slots the local reader lacks is not soft: the record is read-only (see the ownership +rule above). Soft migration should emit warnings, not silent shrugs. Typical warnings: - missing slot defaulted -- unknown extra slot ignored - nullable/default behavior widened - field preserved but not surfaced natively diff --git a/src/GameCult.Caching/Contracts/cultcache-schema-compatibility.md b/src/GameCult.Caching/Contracts/cultcache-schema-compatibility.md index dc7e7bea..269c2f41 100644 --- a/src/GameCult.Caching/Contracts/cultcache-schema-compatibility.md +++ b/src/GameCult.Caching/Contracts/cultcache-schema-compatibility.md @@ -107,10 +107,48 @@ persisted slots honestly: - persisted schema id differs, but the embedded catalog points to a compatible local schema id -- persisted slot is missing locally and can be ignored +- persisted slot is missing locally and the record is read-only until its id is declared compatible + (see Extra slots are read-only, below) - local slot is missing in persisted data and can fall back to the local default value +Extra slots are read-only. A record whose payload carries a slot past the slots its reader declares, at any +depth (the record, a nested object, a list element, a union arm, a unit arm), is loaded read-only: it is +served, `CultStoredDocument.UnreadSlots` names the first type read with extra slots and both counts, and +`CultCache.IsWritable(key)` is false. A staged write or removal of it is refused with +`CultSchemaConflictException` (`UnreadSlots` set; the message names the type and the counts, never a slot +value). A missing trailing slot keeps its default and stays writable; a non-nil value in a retired gap below +the highest key is not extra. Declaring an id compatible consents only to depth-0 slots the schema comparison +reports ignored under that id, and only up to the highest slot it reports: a payload carrying a slot past that +one is not consented to. Any other mark (nested, list element, arm, unit arm, or depth 0 under the reader's +own id) keeps the record read-only whatever is declared. + +A type's declared slots are the array its own formatter writes for one instance, never a reflected member +list, so a non-public `[Key]` member of an `AllowPrivate` type counts. Only a concrete int-keyed +`[MessagePackObject]` type whose formatter is MessagePack's own `[Key]`-layout formatter is checked. Not +checked, and so read past with any extra slots dropped on the next write, as before: + +- a type formatted by anything else: its own `[MessagePackFormatter]`, a formatter of its own that a + `CultCacheFormatterResolver` (or any resolver ahead of MessagePack's) hands out, or the collection formatter + of a `[MessagePackObject]` collection type such as a `List` subclass. Such a formatter owns its layout and + its tolerance. A resolver that hands out MessagePack's own `[Key]`-layout formatter (StandardResolver's, or + StandardResolverAllowPrivate's) does not exempt the type: it is checked and marks; +- a type no instance of which can be written: no write of an uninitialized instance and no write of one + from a parameterless constructor succeeds (for example a `[Key]` getter that throws on an uninitialized + instance and no parameterless constructor). This gap is ruled: `cultlib-gaps:ruling:slot-unmeasurable-type`. + +Measuring runs user code once per type per process, on the first read of the type in a store load: the +type's `[Key]` getters on an uninitialized instance and, if that write throws, its parameterless +constructor (public or not) and the getters on that instance. Any failure of both leaves the type unchecked +for the rest of the process, and nothing else: it does not fail the read. + +`Flatten` of a variant writes its base chain's decoded view into a plain record, so it is refused when any +record on that chain is not owned by its home store (a mark nothing explains, or undeclared drift that drops +slots), with that record's own refusal naming its key. Writing or removing the variant itself stays allowed: +it persists only its delta. `CultCache.IsWritable(key)` is the write's own answer for a removal or a write of +the record's type: it is also false when the record's home store is read-only or no attached store is home to +its type. The one write it does not predict is that flatten. + The cache emits a typed migration report with: - exact vs compatible-drift classification diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index 6f04d99c..48bad3a3 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -516,14 +516,22 @@ namespace GameCult.Caching // resolved to Descriptor, until a write of this record puts it under Descriptor's id (a write of an owned record moves it). public string StoredSchemaId { get; internal set; } - // True when the schema this record was loaded under left none of its persisted slots unread by Descriptor, so the type reads - // it whole and owns it (CultDocumentRegistry.Owns). Set by ToStoredDocument from the schema comparison's ignored slots, + // True when the schema this record was loaded under left none of its persisted slots unread by Descriptor and its payload + // carried none past Descriptor's, so the type reads it whole and owns it (CultDocumentRegistry.Owns). Set by ToStoredDocument from the schema comparison's ignored slots, // before any variant override is shed. A record this cache constructed or wrote is stored under Descriptor's own id, which // the registry declares, so only a loaded record under another id is ever asked. The staging half asks it; // ConditionsHold never reads it. Only the store's loaded view carries it: the cache's resolved copies and flattens are never // asked. internal bool StoredSchemaReadWhole { get; set; } + /// + /// What the store's decode of this record's payload found past the slots this build declares, at any depth; null when none. Set by + /// ToStoredDocument on the store's loaded view only (the cache's resolved copy of a variant does not carry it; ask + /// ). A mark that is not keeps the record + /// unowned even under a declared id: it cannot be written or removed (HeldOwned). + /// + public CultUnreadSlots? UnreadSlots { get; internal set; } + // The catalog entry that publishes StoredSchemaId in the store this record was loaded from, with the id it was published for, // so staging can ask WrittenOwns the question the store's judge asks, over the catalog the record came with. Null for a record // this cache constructed or wrote, which is stored under Descriptor's own id and published by Descriptor's entry. A write that @@ -2300,8 +2308,9 @@ namespace GameCult.Caching // The one-shot rewrite for stores written before element ids existed: upserts every record whose ids exist only in // memory (minted when it loaded), through the normal write path, one commit per home store. Returns how many records - // it rewrote; a second call finds none. A selected record that is neither declared nor read whole is read-only, - // so its home store's commit is refused with CultSchemaConflictException; commits to earlier stores stand. + // it rewrote; a second call finds none. A selected record that is not owned (neither declared nor read whole, or held + // read-only for slots past this build's, see CultStoredDocument.UnreadSlots) is read-only, so its home store's commit is + // refused with CultSchemaConflictException until a build that reads those slots rewrites it; commits to earlier stores stand. public int MintElementIds() { var groups = Held(() => _entries.Values @@ -2656,6 +2665,22 @@ namespace GameCult.Caching return _entries.TryGetValue(key.Value, out var stored) ? stored : null; } + /// + /// Whether a removal of the key, or a write of the record's own type, would be let through: the write's own decision, asked of + /// the record the cache holds. False when no attached store is home to the record's type, when its home store is read-only, or + /// when that store holds it unowned (undeclared drift that drops persisted slots, or a payload carrying slots past this build's, + /// see ). For a key the cache does not hold, the removal check every store makes of it + /// (false when a store lists it foreign). One write it does not predict: a Flatten of a variant whose base chain holds an unowned record is refused, though + /// the variant itself is writable. Live and advisory: the write still decides. + /// + public bool IsWritable(CultRecordKey key) + { + lock (Gate()) + return _entries.TryGetValue(key.Value, out var held) + ? WriteRefusal(held, upsert: false, null, out _) == null + : _stores.All(entry => entry.Store.Refusal(key.Value, null) == null); + } + public object? Get(CultRecordKey key) { lock (Gate()) @@ -3151,6 +3176,8 @@ namespace GameCult.Caching IReadOnlyCollection? removed) { var removedKeys = removed?.ToHashSet(StringComparer.Ordinal) ?? new HashSet(StringComparer.Ordinal); + var batch = new Dictionary(StringComparer.Ordinal); + foreach (var stored in admitted) batch[stored.Key.Value] = stored; var homes = new List(); var globals = new Dictionary(_globals); var evictedKeys = evicted.Select(stored => stored.Key.Value).ToHashSet(StringComparer.Ordinal); @@ -3199,23 +3226,57 @@ namespace GameCult.Caching void Claim(CultStoredDocument stored, bool upsert) { - var home = Home(stored.Descriptor.DocumentType); - if (home == null) + if (WriteRefusal(stored, upsert, batch, out var home) is { } refusal) throw refusal; + if (home != null) homes.Add(home); + } + } + + // The one answer to whether a record may be written (upsert: a write of its type) or its key removed, before anything is staged. + // Claim throws it; IsWritable asks it of the held record's removal form. Null when the write may land; home is where it lands, + // null for a store-less cache. batch holds the records the same batch admits, by key. + private Exception? WriteRefusal(CultStoredDocument stored, bool upsert, IReadOnlyDictionary? batch, out CacheBackingStore? home) + { + home = Home(stored.Descriptor.DocumentType); + if (home == null) + { + if (_stores.Count > 0) + return new InvalidOperationException($"No backing store is home to {stored.Descriptor.SchemaName}."); + // No store, no file to judge, but the same rule: a record is replaced only by its own type's write. + return upsert && _entries.TryGetValue(stored.Key.Value, out var inMemory) && !CacheBackingStore.UpsertOwned(inMemory, stored.Descriptor) + ? CacheBackingStore.Retyped(inMemory, stored.Key.Value, stored.Descriptor) + : null; + } + + if (home.IsReadOnly) + return new InvalidOperationException( + $"{stored.Descriptor.SchemaName} record {stored.Key.Value} routes to read-only backing store {home}."); + return home.Refusal(stored.Key.Value, upsert ? stored.Descriptor : null) + ?? (upsert && stored.Flattens ? FlattenRefusal(stored, batch) : null); + } + + // A flatten writes its base chain's decoded view into a plain record, so it is refused when a record on that chain is not owned + // by its home store (the slots or drift that record's decode dropped would be lost): that ancestor's own refusal. The chain starts + // at the staged delta's base when the variant is flattened as it lands, else at the held variant's; a key the batch admits is + // judged by its own claim, and the walk follows its staged form. + private CultSchemaConflictException? FlattenRefusal(CultStoredDocument flatten, IReadOnlyDictionary? batch) + { + var delta = flatten.Variant ?? (_entries.TryGetValue(flatten.Key.Value, out var variant) ? variant.Variant : null); + var walked = new HashSet(StringComparer.Ordinal) { flatten.Key.Value }; + for (var key = delta?.BaseKey; key != null && walked.Add(key);) + { + CultStoredDocument? ancestor; + if (batch == null || !batch.TryGetValue(key, out ancestor)) { - if (_stores.Count > 0) - throw new InvalidOperationException($"No backing store is home to {stored.Descriptor.SchemaName}."); - // No store, no file to judge, but the same rule: a record is replaced only by its own type's write. - if (upsert && _entries.TryGetValue(stored.Key.Value, out var inMemory) && !CacheBackingStore.UpsertOwned(inMemory, stored.Descriptor)) - throw CacheBackingStore.Retyped(inMemory, stored.Key.Value, stored.Descriptor); - return; + if (!_entries.TryGetValue(key, out ancestor)) + return null; + if (Home(ancestor.Descriptor.DocumentType)?.Refusal(key, null) is { } refusal) + return refusal; } - if (home.IsReadOnly) - throw new InvalidOperationException( - $"{stored.Descriptor.SchemaName} record {stored.Key.Value} routes to read-only backing store {home}."); - home.RefuseUndeclared(stored.Key.Value, upsert ? stored.Descriptor : null); - homes.Add(home); + key = ancestor.Variant?.BaseKey; } + + return null; } private sealed class VariantPlan @@ -3937,11 +3998,21 @@ namespace GameCult.Caching string version, IReadOnlyCollection catalog, Func deserializePayload, + out CultSchemaMigrationReport report) => + ToStoredDocument(record, version, catalog, (type, payload) => new CultDecoded(deserializePayload(type, payload), null), out report); + + // The same, for a decode that notes the extra slots it read past (DeserializeMarked): the mark is turned into + // CultStoredDocument.UnreadSlots here, the one place that knows both the payload's marks and the catalog comparison. + internal CultStoredDocument ToStoredDocument( + CultPersistedRecord record, + string version, + IReadOnlyCollection catalog, + Func deserializePayload, out CultSchemaMigrationReport report) { var resolution = Registry.ResolvePersistedSchemaDetailed(record.SchemaId, catalog); report = resolution.Report; - var readWhole = report.IgnoredExtraSlots.Length == 0; + CultUnreadSlots? unread = null; if (record.Variant != null) { var descriptor = resolution.Descriptor; @@ -3955,13 +4026,22 @@ namespace GameCult.Caching .ToArray(); if (shed.Length > 0) { - // A slot the schema diff already reported is not reported twice. + // A slot the schema diff already reported is not reported twice. One it did not report is a mark nothing explains: + // the variant is read-only and is never rewritten. var unreported = shed.Except(report.IgnoredExtraSlots).ToArray(); + if (unreported.Length > 0) + unread = new CultUnreadSlots( + descriptor.DocumentType.Name, + unreported.Max() + 1, + descriptor.RichMembers.Select(member => member.Slot + 1).DefaultIfEmpty(0).Max(), + 1, + allAtDepthZero: true, + explained: false); report.IgnoredExtraSlots = report.IgnoredExtraSlots.Concat(unreported).OrderBy(slot => slot).ToArray(); report.Warnings = report.Warnings.Concat(unreported.Select(slot => new CultSchemaMigrationWarning { Code = "ignored_extra_slot", - Message = $"Variant {record.Key} overrides slot {slot}, which {descriptor.DocumentType.Name} does not have; the override is ignored and shed on flush." + Message = $"Variant {record.Key} overrides slot {slot}, which {descriptor.DocumentType.Name} does not have; the variant is read-only." })).ToArray(); } @@ -3977,16 +4057,17 @@ namespace GameCult.Caching null) { StoredSchemaId = record.SchemaId, - StoredSchemaReadWhole = readWhole, + StoredSchemaReadWhole = report.IgnoredExtraSlots.Length == 0, + UnreadSlots = unread, StoredPublishedBy = PublishedByIn(record.SchemaId, catalog), StoredVersion = version }; } - object document; + CultDecoded decoded; try { - document = deserializePayload(resolution.Descriptor.DocumentType, record.Payload); + decoded = deserializePayload(resolution.Descriptor.DocumentType, record.Payload); } catch (Exception exception) when (exception is not OperationCanceledException) { @@ -3995,14 +4076,20 @@ namespace GameCult.Caching for (var cause = exception; cause != null; cause = cause.InnerException) causes.Add(cause.Message); throw new InvalidDataException($"Record '{record.Key}' (schema '{record.SchemaId}') cannot be decoded: {string.Join(" <- ", causes.Distinct())}", exception); } + // Explained only when every mark is at depth 0 and the schema comparison under this id reported every slot the mark carries + // past the reader: consent reaches no slot above the highest reported (none when nothing was reported). + unread = decoded.UnreadSlots is { } marked + ? marked.Explaining(marked.AllAtDepthZero && marked.SlotCount <= report.IgnoredExtraSlots.DefaultIfEmpty(-1).Max() + 1) + : null; return new CultStoredDocument( new CultRecordKey(record.Key), record.StoredAt, resolution.Descriptor, - document) + decoded.Document) { StoredSchemaId = record.SchemaId, - StoredSchemaReadWhole = readWhole, + StoredSchemaReadWhole = report.IgnoredExtraSlots.Length == 0 && unread == null, + UnreadSlots = unread, StoredPublishedBy = PublishedByIn(record.SchemaId, catalog), StoredVersion = version }; @@ -4041,22 +4128,37 @@ namespace GameCult.Caching // catalog entry that published the held record's id when it was loaded, the question the store's judge asks of the file. internal void RefuseUndeclared(string key, CultDocumentDescriptor? written) { - var foreign = _foreignRecords.FirstOrDefault(record => string.Equals(record.Key, key, StringComparison.Ordinal)); - if (foreign != null) - throw Overwrites(null, new[] { (foreign.Key, foreign.SchemaId, foreign.SchemaName) }); - if (Entries.TryGetValue(key, out var held) && !HeldOwned(held, written)) - throw Overwrites(null, new[] { (key, held.StoredSchemaId, held.Descriptor.SchemaName) }); + if (Refusal(key, written) is { } refusal) throw refusal; } + // The refusal of a write (of the written type) or a removal (written null) of the key, or null when this store's view lets it + // through. The one answer behind RefuseUndeclared and CultCache.IsWritable, so the query and the write cannot disagree. + internal CultSchemaConflictException? Refusal(string key, CultDocumentDescriptor? written) + { + var foreign = _foreignRecords.FirstOrDefault(record => string.Equals(record.Key, key, StringComparison.Ordinal)); + if (foreign != null) + return Overwrites(null, new[] { (foreign.Key, foreign.SchemaId, foreign.SchemaName) }); + if (!Entries.TryGetValue(key, out var held) || HeldOwned(held, written)) + return null; + return held.UnreadSlots is { Explained: false } unread + ? UnreadSlotsRefusal(held, key, unread) + : Overwrites(null, new[] { (key, held.StoredSchemaId, held.Descriptor.SchemaName) }); + } + + // The one ownership predicate of a held record. A mark nothing explains (anything but a depth-0 slot the catalog comparison + // reported ignored) keeps the record unowned whatever is declared: declaring the reader's own id consents to nothing. private bool HeldOwned(CultStoredDocument held, CultDocumentDescriptor? written) => written == null - ? Registry.Declared(held.StoredSchemaId) || held.StoredSchemaReadWhole + ? !Unexplained(held) && (Registry.Declared(held.StoredSchemaId) || held.StoredSchemaReadWhole) : UpsertOwned(held, written); + private static bool Unexplained(CultStoredDocument held) => held.UnreadSlots is { Explained: false }; + // Whether a write of the written type may replace the record a cache holds: WrittenOwns over the entry that published the held // record's id when it was loaded (a record this cache wrote is published by its own type). The one answer for a store-backed // cache's staging and a store-less cache's. internal static bool UpsertOwned(CultStoredDocument held, CultDocumentDescriptor written) => + !Unexplained(held) && CultDocumentRegistry.WrittenOwns( written.ToCatalogEntry(), held.StoredSchemaId, @@ -4358,6 +4460,16 @@ namespace GameCult.Caching refused[0].Key, inner); + // The refusal of a write or removal of a record held read-only for the slots its payload carries past this build's. It names the + // type and the counts, never a slot value. + private CultSchemaConflictException UnreadSlotsRefusal(CultStoredDocument held, string key, CultUnreadSlots unread) => new( + $"{held.Descriptor.SchemaName} record {key} is read-only: {unread.TypeName} was read with {unread.SlotCount} slots where this build declares {unread.DeclaredCount} " + + $"({unread.Occurrences} object(s) in the record). Writing it would drop data a newer writer stored; read it with types that declare those slots, or write it from that build.", + held.StoredSchemaId, + new[] { held.Descriptor.SchemaName }, + key, + unread); + // A flush or commit succeeded: nothing staged is left, writes or checks. protected void MarkFlushSucceeded() { @@ -4461,6 +4573,9 @@ namespace GameCult.Caching protected abstract byte[] SerializePayload(object document, Type documentType); protected abstract object DeserializePayload(Type documentType, byte[] payload); + // A decode that also notes the slots it read past. A store kind that does not override it never marks. + internal virtual CultDecoded DeserializeMarked(Type documentType, byte[] payload) => new(DeserializePayload(documentType, payload), null); + public override string ToString() => FileInfo.FullName; public override void PullAll() => Held(PullAllCore); @@ -4495,7 +4610,7 @@ namespace GameCult.Caching continue; } - var stored = ToStoredDocument(record, version, snapshot.SchemaCatalog, DeserializePayload, out var report); + var stored = ToStoredDocument(record, version, snapshot.SchemaCatalog, DeserializeMarked, out var report); reports.Add(report); persisted[stored.Key.Value] = stored; } @@ -4963,7 +5078,7 @@ namespace GameCult.Caching var record = durable?.Snapshot.Records.FirstOrDefault(candidate => string.Equals(candidate.Key, key, StringComparison.Ordinal)); if (record == null || Foreign(record, durable!.Snapshot.SchemaCatalog, durable.Version(key)) != null) return null; - return ToStoredDocument(record, durable.Version(key), durable.Snapshot.SchemaCatalog, DeserializePayload, out _); + return ToStoredDocument(record, durable.Version(key), durable.Snapshot.SchemaCatalog, DeserializeMarked, out _); } // The file now holds every record written, under the id and at the storedAt the write gave it, and the version of those bytes: diff --git a/src/GameCult.Caching/CultSchemaConflictException.cs b/src/GameCult.Caching/CultSchemaConflictException.cs index bbaff988..376b4c08 100644 --- a/src/GameCult.Caching/CultSchemaConflictException.cs +++ b/src/GameCult.Caching/CultSchemaConflictException.cs @@ -5,7 +5,8 @@ namespace GameCult.Caching /// /// A write the catalog cannot describe: two schemas that share a schema id and disagree on the schema name (both arrived with the /// records, neither registered), a record whose schema id no chosen entry publishes, or a write that would replace or remove a - /// . Nothing is written, so the store stays as it was. It names the id, the schema names involved + /// , or a write or removal of a record held read-only because its payload carries slots past the + /// reader's declared slots ( is set). Nothing is written, so the store stays as it was. It names the id, the schema names involved /// and a record key. /// public sealed class CultSchemaConflictException : InvalidOperationException @@ -27,8 +28,18 @@ namespace GameCult.Caching RecordKey = recordKey; } + /// The refusal of a record held read-only for the slots it carries past this build's: the same refusal, with the finding. + public CultSchemaConflictException(string message, string schemaId, string[] schemaNames, string recordKey, CultUnreadSlots unreadSlots) + : this(message, schemaId, schemaNames, recordKey) + { + UnreadSlots = unreadSlots; + } + public string SchemaId { get; } public string[] SchemaNames { get; } public string RecordKey { get; } + + /// Set when the refusal is for unread slots; null for every other refusal. + public CultUnreadSlots? UnreadSlots { get; } } } diff --git a/src/GameCult.Caching/CultUnreadSlots.cs b/src/GameCult.Caching/CultUnreadSlots.cs new file mode 100644 index 00000000..9e8412f7 --- /dev/null +++ b/src/GameCult.Caching/CultUnreadSlots.cs @@ -0,0 +1,54 @@ +namespace GameCult.Caching +{ + /// + /// What a store load found in a record's payload past the slots this build declares, at any depth: the first type read with extra + /// slots and how many slots it carried against how many it declares, and how many objects in the record did. It holds no slot + /// value, so it can be named in a refusal. A record carrying it is read-only () unless every + /// mark is explained. + /// + public sealed class CultUnreadSlots + { + internal CultUnreadSlots(string typeName, int slotCount, int declaredCount, int occurrences, bool allAtDepthZero, bool explained) + { + TypeName = typeName; + SlotCount = slotCount; + DeclaredCount = declaredCount; + Occurrences = occurrences; + AllAtDepthZero = allAtDepthZero; + Explained = explained; + } + + /// The first type read with extra slots. + public string TypeName { get; } + + /// The slots that object carried. + public int SlotCount { get; } + + /// The slots this build declares for that type: the array its own formatter writes for one instance. + public int DeclaredCount { get; } + + /// The objects in the record that carried extra slots. + public int Occurrences { get; } + + /// + /// True only when every mark is at depth 0 and the schema comparison under the id the record was stored under reported every + /// slot the mark carries past the reader ( is at most the highest reported slot plus one): the loss a + /// compatible-id declaration consents to (cultlib-gaps:ruling:slot-mark-consent). Any other mark leaves the record read-only + /// whatever is declared. + /// + public bool Explained { get; } + + // Every mark the decode noted sat in the record's own object, the half of "explained" the payload decides. + internal bool AllAtDepthZero { get; } + + internal CultUnreadSlots Explaining(bool explained) => + new(TypeName, SlotCount, DeclaredCount, Occurrences, AllAtDepthZero, explained); + } + + // A payload decoded by a store kind, with the extra slots the decode noted. A store kind that does not note them reports none. + internal readonly struct CultDecoded(object document, CultUnreadSlots? unreadSlots) + { + public object Document { get; } = document; + public CultUnreadSlots? UnreadSlots { get; } = unreadSlots; + } +} diff --git a/tests/GameCult.Caching.GeneratorConsumer/GameCult.Caching.GeneratorConsumer.csproj b/tests/GameCult.Caching.GeneratorConsumer/GameCult.Caching.GeneratorConsumer.csproj index 6d443a4f..4231195a 100644 --- a/tests/GameCult.Caching.GeneratorConsumer/GameCult.Caching.GeneratorConsumer.csproj +++ b/tests/GameCult.Caching.GeneratorConsumer/GameCult.Caching.GeneratorConsumer.csproj @@ -4,6 +4,9 @@ net10.0 enable false + + $(NoWarn);CS0436 diff --git a/tests/GameCult.Caching.GeneratorConsumer/Program.cs b/tests/GameCult.Caching.GeneratorConsumer/Program.cs index 2511ed80..ab83330f 100644 --- a/tests/GameCult.Caching.GeneratorConsumer/Program.cs +++ b/tests/GameCult.Caching.GeneratorConsumer/Program.cs @@ -20,7 +20,8 @@ var documents = new (Type Type, object Value)[] foreach (var (type, _) in documents) { - var formatter = options.Resolver.GetFormatterDynamic(type)!.GetType(); + // The guard wraps a measured type's formatter; the generated formatter it delegates to is the one that runs. + var formatter = CultUnionGuard.Inner(options.Resolver.GetFormatterDynamic(type)!).GetType(); if (formatter.Assembly.IsDynamic || !(formatter.FullName ?? "").Contains("GeneratedMessagePackResolver")) return Fail($"{type.Name} is formatted by {formatter.FullName}, not a generated formatter"); } diff --git a/tests/GameCult.Caching.Tests/AssemblyInfo.cs b/tests/GameCult.Caching.Tests/AssemblyInfo.cs index 708f0a09..8d619b43 100644 --- a/tests/GameCult.Caching.Tests/AssemblyInfo.cs +++ b/tests/GameCult.Caching.Tests/AssemblyInfo.cs @@ -10,6 +10,8 @@ using MessagePack.Formatters; [assembly: CultCacheFormatterResolver(typeof(UnionArmGuardTests.OrderResolver))] [assembly: CultCacheFormatterResolver(typeof(UnionArmGuardTests.ListClaimResolver))] [assembly: CultCacheFormatterResolver(typeof(UnionArmGuardTests.FbLaterClaimResolver))] +[assembly: CultCacheFormatterResolver(typeof(SlotReadOnlyTests.ResolvedBagResolver))] +[assembly: CultCacheFormatterResolver(typeof(SlotReadOnlyTests.RelayResolver))] namespace GameCult.Caching.Tests { diff --git a/tests/GameCult.Caching.Tests/SlotReadOnlyTests.cs b/tests/GameCult.Caching.Tests/SlotReadOnlyTests.cs new file mode 100644 index 00000000..eb8bd64b --- /dev/null +++ b/tests/GameCult.Caching.Tests/SlotReadOnlyTests.cs @@ -0,0 +1,639 @@ +#nullable enable +using System; +using System.Buffers; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using GameCult.Caching.MessagePack; +using MessagePack; +using MessagePack.Formatters; +using NUnit.Framework; +using Surface = GameCult.Caching.Tests.UnionParityVectorTests.UnionSurface; + +namespace GameCult.Caching.Tests +{ + // cultlib-gaps:ruling:extra-trailing-slots and :slot-mark-consent. A record whose payload carries a slot past its reader's declared + // slots, at any depth, is held read-only: every staged write or removal of it is refused before anything is staged. A compatible-id + // declaration consents only to depth-0 slots the catalog comparison reports ignored under a listed compatible id. + public class SlotReadOnlyTests + { + private const string Extra = "extra-slot"; + + // Slot 1 and 2 are retired gaps. + [CultDocument("tests.slot_gap", "tests.slot_gap.v1")] + [MessagePackObject] + public sealed class GapDoc + { + [Key(0)] [CultName] public string Name = string.Empty; + [Key(3)] public string Tail = string.Empty; + } + + private static readonly CultDocumentRegistry SurfaceRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(Surface) }); + private static readonly CultDocumentRegistry GapRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(GapDoc) }); + private static readonly CultRecordKey Marked = new("marked"); + private static readonly CultRecordKey Clean = new("clean"); + private static readonly CultRecordKey Unheld = new("unheld"); + + private string _directory = string.Empty; + + [SetUp] + public void CreateDirectory() + { + _directory = Path.Combine(Path.GetTempPath(), $"cultlib-slotro-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_directory); + } + + [TearDown] + public void DeleteDirectory() + { + if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true); + } + + private static CultCache Open(string path, CultDocumentRegistry registry) => + CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = registry }); + + private static byte[] Emit(WriterAction action) + { + var buffer = new ArrayBufferWriter(); + var writer = new MessagePackWriter(buffer); + action(ref writer); + writer.Flush(); + return buffer.WrittenSpan.ToArray(); + } + + private delegate void WriterAction(ref MessagePackWriter writer); + + // A store of two records of the given type, the marked one carrying the payload and the clean one an ordinary value. + private string Seed(CultDocumentRegistry registry, Type type, Func clean, byte[] markedPayload, string name = "slots.cc") + { + var path = Path.Combine(_directory, name); + using (var cache = Open(path, registry)) + { + cache.UpsertAsync(type, clean(), Marked).GetAwaiter().GetResult(); + cache.UpsertAsync(type, clean(), Clean).GetAwaiter().GetResult(); + cache.FlushAsync().GetAwaiter().GetResult(); + } + + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); + snapshot.Records.Single(record => record.Key == Marked.Value).Payload = markedPayload; + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + return path; + } + + private string SeedSurface(string vector) => + Seed(SurfaceRegistry, typeof(Surface), () => new Surface { Id = "x" }, VectorFiles.Committed("union-parity", vector)); + + private static CultStoredDocument Stored(CultCache cache, CultRecordKey key) => cache.GetStored(key)!; + + // ---- a mark at every depth, with the type and both counts ---- + + [TestCase("extra-slot-record.msgpack", "UnionSurface", 8, 7, 1)] + [TestCase("extra-slot-nested-record.msgpack", nameof(UnionParityVectorTests.Composition), 3, 2, 1)] + [TestCase("extra-slot-arm.msgpack", nameof(UnionParityVectorTests.Box), 3, 2, 1)] + [TestCase("extra-slot-list-element.msgpack", nameof(UnionParityVectorTests.Text), 3, 2, 1)] + [TestCase("depth-skipped-at-limit.msgpack", "UnionSurface", 8, 7, 1)] + public void AMarkAtAnyDepthHoldsTheRecordReadOnly(string vector, string typeName, int slots, int declared, int occurrences) + { + var path = SeedSurface(vector); + using var cache = Open(path, SurfaceRegistry); + var unread = Stored(cache, Marked).UnreadSlots; + Assert.That(unread, Is.Not.Null, vector); + Assert.That(unread!.TypeName, Is.EqualTo(typeName), vector); + Assert.That(unread.SlotCount, Is.EqualTo(slots), vector); + Assert.That(unread.DeclaredCount, Is.EqualTo(declared), vector); + Assert.That(unread.Occurrences, Is.EqualTo(occurrences), vector); + Assert.That(unread.Explained, Is.False, "the reader's own id is not a compatible-id declaration: nothing explains the mark"); + Assert.That(cache.IsWritable(Marked), Is.False, vector); + Assert.That(cache.Get(Marked), Is.Not.Null, "a read-only record is still served"); + Assert.That(Stored(cache, Clean).UnreadSlots, Is.Null); + Assert.That(cache.IsWritable(Clean), Is.True); + Assert.That(cache.IsWritable(Unheld), Is.True, "a key no store holds is writable"); + } + + // ---- every staged write and removal of a marked key is refused, nothing else is ---- + + private static void AssertSlotRefusal(TestDelegate act, string what, string type, int slots, int declared) + { + var refusal = Assert.Throws(act, what)!; + Assert.That(refusal.UnreadSlots, Is.Not.Null, what); + Assert.That(refusal.RecordKey, Is.EqualTo("marked"), what); + Assert.That(refusal.Message, Does.Contain("is read-only: " + type + " was read with " + slots + " slots where this build declares " + declared) + .And.Contain("record marked"), what); + Assert.That(refusal.Message, Does.Not.Contain(Extra), $"{what}: the refusal never echoes a slot value"); + } + + [Test] + public void EveryWriteAndRemovalOfAMarkedRecordIsRefusedAndAnotherKeyStillWrites() + { + var path = SeedSurface("extra-slot-nested-record.msgpack"); + var before = File.ReadAllBytes(path); + using (var cache = Open(path, SurfaceRegistry)) + { + var composition = nameof(UnionParityVectorTests.Composition); + AssertSlotRefusal(() => cache.Commit(batch => batch.Upsert(new Surface { Id = "new" }, new CultRecordHandle(Marked))), "upsert", composition, 3, 2); + AssertSlotRefusal(() => cache.Commit(batch => batch.UpsertVariant(Marked, Clean)), "variant", composition, 3, 2); + AssertSlotRefusal(() => cache.Commit(batch => batch.Remove(Marked)), "remove", composition, 3, 2); + AssertSlotRefusal(() => cache.Commit(batch => { batch.Upsert(new Surface { Id = "other" }, new CultRecordHandle(Clean)); batch.Remove(Marked); }), "mixed batch", composition, 3, 2); + Assert.That(File.ReadAllBytes(path), Is.EqualTo(before), "refused writes leave the store as it was"); + Assert.That(cache.Commit(batch => batch.Upsert(new Surface { Id = "changed" }, new CultRecordHandle(Clean))), Is.True); + } + + Assert.That(CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)).Records.Count, Is.EqualTo(2)); + } + + // ---- consent ---- + + [Test] + public void ADeclaredIdDoesNotConsentToAnUnexplainedMark() + { + // The reader's own id is declared; the mark at depth 0 is in no compatible id's comparison: still read-only. + var path = SeedSurface("extra-slot-record.msgpack"); + using var cache = Open(path, SurfaceRegistry); + AssertSlotRefusal(() => cache.Commit(batch => batch.Upsert(new Surface { Id = "new" }, new CultRecordHandle(Marked))), "depth 0 under the reader's own id", "UnionSurface", 8, 7); + AssertSlotRefusal(() => cache.Commit(batch => batch.Remove(Marked)), "removal", "UnionSurface", 8, 7); + } + + // Every row of slot-outcomes.msgpack, from the committed files alone: the mark, Explained and IsWritable of each record of + // extra-slot-store.cc and extra-slot-consent.cc, and a removal of the key refused exactly where IsWritable is false. + [Test] + public void EverySlotOutcomeRowHoldsAndARemovalIsRefusedExactlyWhereTheRecordIsNotWritable() + { + var reader = new MessagePackReader(VectorFiles.Committed("union-parity", "slot-outcomes.msgpack")); + var rows = reader.ReadArrayHeader(); + Assert.That(rows, Is.EqualTo(8)); + for (var i = 0; i < rows; i++) + { + Assert.That(reader.ReadArrayHeader(), Is.EqualTo(9)); + var store = reader.ReadString()!; + var key = new CultRecordKey(reader.ReadString()!); + var writable = reader.ReadBoolean(); + bool? explained = reader.TryReadNil() ? null : reader.ReadBoolean(); + var type = reader.TryReadNil() ? null : reader.ReadString(); + var counts = new int?[3]; + for (var c = 0; c < 3; c++) counts[c] = reader.TryReadNil() ? null : reader.ReadInt32(); + var (slots, declared, occurrences) = (counts[0], counts[1], counts[2]); + var flattenRefusedBy = reader.TryReadNil() ? null : reader.ReadString(); + var row = $"{store} {key.Value}"; + + var path = Path.Combine(_directory, $"{i}-{store}"); + File.WriteAllBytes(path, VectorFiles.Committed("union-parity", store)); + var before = File.ReadAllBytes(path); + using var cache = Open(path, UnionParityVectorTests.SlotReader(store)); + var unread = Stored(cache, key).UnreadSlots; + if (explained == null) + { + Assert.That(unread, Is.Null, row); + } + else + { + Assert.That(unread, Is.Not.Null, row); + Assert.That((unread!.Explained, unread.TypeName, unread.SlotCount, unread.DeclaredCount, unread.Occurrences), + Is.EqualTo((explained.Value, type, slots, declared, occurrences)), row); + } + + Assert.That(cache.IsWritable(key), Is.EqualTo(writable), row); + Assert.That(cache.IsWritable(Unheld), Is.True, "a key no store holds is writable"); + if (flattenRefusedBy != null) + { + var refusal = Assert.Throws(() => cache.Commit(batch => batch.Flatten(key)), $"{row}: flatten")!; + Assert.That(refusal.RecordKey, Is.EqualTo(flattenRefusedBy), $"{row}: the refusal is the base's own"); + Assert.That(File.ReadAllBytes(path), Is.EqualTo(before), $"{row}: a refused flatten leaves the store as it was"); + } + + if (writable) + { + Assert.That(cache.Commit(batch => batch.Remove(key)), Is.True, row); + } + else + { + var refusal = Assert.Throws(() => cache.Commit(batch => batch.Remove(key)), row)!; + Assert.That(refusal.UnreadSlots, Is.Not.Null, row); + Assert.That(File.ReadAllBytes(path), Is.EqualTo(before), $"{row}: a refused removal leaves the store as it was"); + } + } + } + + // Consent reaches only the slots the catalog comparison reports: the wide id's catalog reports slot 7, so a payload carrying + // slots 7 and 8 at depth 0 is not consented to, and a payload carrying slot 7 alone is. + [Test] + public void ConsentStopsAtTheSlotsTheCatalogReports() + { + var path = Path.Combine(_directory, "extra-slot-consent.cc"); + File.WriteAllBytes(path, VectorFiles.Committed("union-parity", "extra-slot-consent.cc")); + var before = File.ReadAllBytes(path); + using var cache = Open(path, UnionParityVectorTests.SlotReader("extra-slot-consent.cc")); + var over = new CultRecordHandle(new CultRecordKey("over-consent")); + AssertWriteAgrees(cache, over.Key, () => cache.Commit(batch => batch.Upsert(new UnionParityVectorTests.SurfaceDeclaring { Id = "over" }, over))); + Assert.That(File.ReadAllBytes(path), Is.EqualTo(before), "the refused write leaves the store as it was"); + var consented = new CultRecordHandle(new CultRecordKey("consented")); + AssertWriteAgrees(cache, consented.Key, () => cache.Commit(batch => batch.Upsert(new UnionParityVectorTests.SurfaceDeclaring { Id = "consented" }, consented))); + Assert.That(cache.IsWritable(consented.Key), Is.True); + } + + // ---- carried verbatim ---- + + [Test] + public void AMarkedRecordIsCarriedByteForByteWhenAnotherRecordIsWritten() + { + var path = Path.Combine(_directory, "extra-slot-store.cc"); + File.WriteAllBytes(path, VectorFiles.Committed("union-parity", "extra-slot-store.cc")); + var before = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); + var original = before.Records.Single(record => record.Key == Marked.Value); + using (var cache = Open(path, SurfaceRegistry)) + { + Assert.That(cache.IsWritable(Marked), Is.False); + Assert.That(cache.Commit(batch => batch.Upsert(new Surface { Id = "changed" }, new CultRecordHandle(Clean))), Is.True); + } + + var after = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); + var carried = after.Records.Single(record => record.Key == Marked.Value); + Assert.That(carried.Payload, Is.EqualTo(original.Payload)); + Assert.That(carried.SchemaId, Is.EqualTo(original.SchemaId)); + Assert.That(carried.StoredAt, Is.EqualTo(original.StoredAt)); + Assert.That(after.SchemaCatalog.Select(CultDocumentMessagePackSerialization.SerializeCatalogEntry).Select(Convert.ToHexString), + Is.EqualTo(before.SchemaCatalog.Select(CultDocumentMessagePackSerialization.SerializeCatalogEntry).Select(Convert.ToHexString))); + Assert.That(after.Records.Single(record => record.Key == Clean.Value).Payload, Is.Not.EqualTo(before.Records.Single(record => record.Key == Clean.Value).Payload)); + } + + // ---- slots short of the reader's, and gaps below the highest key, do not mark ---- + + [Test] + public void MissingTrailingSlotsDefaultAndAGapSlotDoesNotMark() + { + var short1 = Emit((ref MessagePackWriter w) => { w.WriteArrayHeader(1); w.Write("short"); }); + var path = Seed(GapRegistry, typeof(GapDoc), () => new GapDoc { Name = "g" }, short1); + using (var cache = Open(path, GapRegistry)) + { + Assert.That(cache.Get(Marked)!.Tail, Is.EqualTo(string.Empty), "a missing trailing slot keeps its default"); + Assert.That(Stored(cache, Marked).UnreadSlots, Is.Null); + Assert.That(cache.IsWritable(Marked), Is.True); + Assert.That(cache.Commit(batch => batch.Upsert(new GapDoc { Name = "short", Tail = "t" }, new CultRecordHandle(Marked))), Is.True); + } + + var gapped = Emit((ref MessagePackWriter w) => { w.WriteArrayHeader(4); w.Write("gap"); w.WriteNil(); w.Write("in a retired slot"); w.Write("tail"); }); + var gapPath = Seed(GapRegistry, typeof(GapDoc), () => new GapDoc { Name = "g" }, gapped, "gap.cc"); + using (var cache = Open(gapPath, GapRegistry)) + { + Assert.That(Stored(cache, Marked).UnreadSlots, Is.Null, "a non-nil value in a retired gap below the highest key does not mark"); + Assert.That(cache.IsWritable(Marked), Is.True); + } + } + + // ---- the catalog's consent still works, and is reported as explained ---- + + [CultDocument("tests.slot_wide", "tests.slot_wide.v1")] + [MessagePackObject] + public sealed class WideDoc + { + [Key(0)] [CultName] public string Name = string.Empty; + [Key(1)] public string Colour = string.Empty; + [Key(2)] public string Extra = string.Empty; + } + + [CultDocument("tests.slot_wide", "tests.slot_wide.v2")] + [MessagePackObject] + public sealed class NarrowDoc + { + [Key(0)] [CultName] public string Name = string.Empty; + [Key(1)] public string Colour = string.Empty; + } + + [Test] + public void ADepthZeroSlotTheCatalogReportsIsExplainedAndTheUndeclaredRecordStaysUnwritable([Values] bool directory) + { + var path = Path.Combine(_directory, directory ? "store" : "store.cc"); + CacheBackingStore Store() => directory ? new DirectoryMessagePackBackingStore(path) : new SingleFileMessagePackBackingStore(path); + using (var writer = new CultCache(CultDocumentRegistry.ForTypes(new[] { typeof(WideDoc) }))) + { + writer.AddBackingStore(Store()); + Assert.That(writer.Commit(batch => batch.Upsert(new WideDoc { Name = "w", Extra = "kept?" }, new CultRecordHandle(Marked))), Is.True); + } + + using var reader = new CultCache(CultDocumentRegistry.ForTypes(new[] { typeof(NarrowDoc) })); + reader.AddBackingStore(Store()); + var unread = Stored(reader, Marked).UnreadSlots; + Assert.That(unread, Is.Not.Null, "the store kind decodes through the marking path"); + Assert.That(unread!.Explained, Is.True, "depth 0 and reported by the catalog comparison"); + Assert.That(unread.TypeName, Is.EqualTo(nameof(NarrowDoc))); + Assert.That(unread.SlotCount, Is.EqualTo(3)); + Assert.That(unread.DeclaredCount, Is.EqualTo(2)); + Assert.That(reader.IsWritable(Marked), Is.False, "the wide id is undeclared: unowned, though the mark is explained"); + Assert.Throws(() => reader.Commit(batch => batch.Remove(Marked))); + } + + // ---- declared slots are the slots the type's own formatter writes ---- + + // MessagePack writes a non-public [Key] member of an AllowPrivate type: 2 slots, where a public-member count says 1. + [MessagePackObject(AllowPrivate = true)] + public sealed class PrivInner + { + [Key(0)] public string A = string.Empty; + [Key(1)] internal string B = string.Empty; + } + + // A [Key] getter that throws on an uninitialized instance; the parameterless constructor's instance is written instead. + [MessagePackObject] + public sealed class Counted + { + private List _items = new(); + [Key(0)] public int Count { get => _items.Count; set => _items = Enumerable.Repeat(string.Empty, value).ToList(); } + } + + // The same getter and no parameterless constructor: no instance can be written, so the guard leaves the type alone. + [MessagePackObject] + public sealed class Uncounted + { + private readonly List _items; + [SerializationConstructor] public Uncounted(int count) => _items = Enumerable.Repeat(string.Empty, count).ToList(); + [Key(0)] public int Count => _items.Count; + } + + // A type naming its own formatter owns its layout: one [Key] slot, but the formatter writes the item count and each item. + [MessagePackObject] + [MessagePackFormatter(typeof(BagFormatter))] + public sealed class Bag + { + [Key(0)] public List Items = new(); + } + + public sealed class BagFormatter : IMessagePackFormatter + { + public void Serialize(ref MessagePackWriter writer, Bag? value, MessagePackSerializerOptions options) + { + if (value == null) { writer.WriteNil(); return; } + writer.WriteArrayHeader(1 + value.Items.Count); + writer.Write(value.Items.Count); + foreach (var item in value.Items) writer.Write(item); + } + + public Bag? Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + { + if (reader.TryReadNil()) return null; + var count = reader.ReadArrayHeader(); + reader.ReadInt32(); + var bag = new Bag(); + for (var i = 1; i < count; i++) bag.Items.Add(reader.ReadString()!); + return bag; + } + } + + // A [MessagePackObject] type an assembly resolver formats as [count, items...], with no [MessagePackFormatter] attribute: the + // resolver owns its layout. + [MessagePackObject] + public sealed class ResolvedBag + { + [Key(0)] public List Items = new(); + } + + public sealed class ResolvedBagResolver : IFormatterResolver + { + public static readonly IFormatterResolver Instance = new ResolvedBagResolver(); + private static readonly IMessagePackFormatter Formatter = new ResolvedBagFormatter(); + + public IMessagePackFormatter? GetFormatter() => typeof(T) == typeof(ResolvedBag) ? (IMessagePackFormatter)Formatter : null; + + private sealed class ResolvedBagFormatter : IMessagePackFormatter + { + private static readonly BagFormatter Bag = new(); + + public void Serialize(ref MessagePackWriter writer, ResolvedBag? value, MessagePackSerializerOptions options) => + Bag.Serialize(ref writer, value == null ? null : new Bag { Items = value.Items }, options); + + public ResolvedBag? Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) => + Bag.Deserialize(ref reader, options) is { } bag ? new ResolvedBag { Items = bag.Items } : null; + } + } + + // A type an assembly resolver claims by handing out MessagePack's own [Key]-layout formatter: still measured. + [MessagePackObject] + public sealed class Relayed + { + [Key(0)] public string A = string.Empty; + } + + public sealed class RelayResolver : IFormatterResolver + { + public static readonly IFormatterResolver Instance = new RelayResolver(); + + public IMessagePackFormatter? GetFormatter() => + typeof(T) == typeof(Relayed) ? global::MessagePack.Resolvers.StandardResolver.Instance.GetFormatter() : null; + } + + // A [MessagePackObject] collection: MessagePack writes it as its items, not by [Key]. + [MessagePackObject] + public class TagList : List { } + + [CultDocument("tests.slot_measured", "tests.slot_measured.v1")] + [MessagePackObject] + public sealed class MeasuredDoc + { + [Key(0)] [CultName] public string Name = string.Empty; + [Key(1)] public PrivInner? Private; + [Key(2)] public Counted? Counted; + [Key(3)] public Uncounted? Uncounted; + [Key(4)] public Bag? Bag; + [Key(5)] public ResolvedBag? Resolved; + [Key(6)] public TagList? Tags; + [Key(7)] public Relayed? Relayed; + } + + private static readonly CultDocumentRegistry MeasuredRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(MeasuredDoc) }); + + private static MeasuredDoc Measured(string member) => member switch + { + "private" => new MeasuredDoc { Name = member, Private = new PrivInner { A = "a", B = "bee" } }, + "constructed" => new MeasuredDoc { Name = member, Counted = new Counted { Count = 2 } }, + "unmeasurable" => new MeasuredDoc { Name = member, Uncounted = new Uncounted(2) }, + "own-formatter" => new MeasuredDoc { Name = member, Bag = new Bag { Items = { "x", "y", "z" } } }, + "resolver-formatted" => new MeasuredDoc { Name = member, Resolved = new ResolvedBag { Items = { "x", "y", "z" } } }, + "collection" => new MeasuredDoc { Name = member, Tags = new TagList { "a", "b", "c" } }, + _ => throw new ArgumentOutOfRangeException(nameof(member), member, null) + }; + + [TestCase("private", TestName = "APrivateKeyedMemberDoesNotMark")] + [TestCase("constructed", TestName = "ATypeMeasuredThroughItsConstructorDoesNotMark")] + [TestCase("unmeasurable", TestName = "AnUnmeasurableTypeIsNotGuarded")] + [TestCase("own-formatter", TestName = "ATypeWithItsOwnFormatterIsNotMeasuredByKeys")] + [TestCase("resolver-formatted", TestName = "ATypeAResolverFormatsIsNotMeasuredByKeys")] + [TestCase("collection", TestName = "AMessagePackObjectCollectionIsNotMeasuredByKeys")] + public void ARecordWrittenByItsOwnTypeDoesNotMark(string member) + { + var path = Path.Combine(_directory, "measured.cc"); + using (var cache = Open(path, MeasuredRegistry)) + { + Assert.That(cache.Commit(batch => batch.Upsert(Measured(member), new CultRecordHandle(Marked))), Is.True); + } + + using (var cache = Open(path, MeasuredRegistry)) + { + Assert.That(Stored(cache, Marked).UnreadSlots, Is.Null, member); + Assert.That(cache.IsWritable(Marked), Is.True, member); + Assert.That(cache.Commit(batch => batch.Upsert(Measured(member), new CultRecordHandle(Marked))), Is.True, member); + Assert.That(cache.Commit(batch => batch.Remove(Marked)), Is.True, member); + } + } + + // One slot past what the formatter writes still marks, with the written count as declared. + [Test] + public void AnExtraSlotPastTheWrittenCountStillMarks() + { + var privatePayload = Emit((ref MessagePackWriter w) => + { + w.WriteArrayHeader(5); w.Write("p"); + w.WriteArrayHeader(3); w.Write("a"); w.Write("bee"); w.Write(Extra); + w.WriteNil(); w.WriteNil(); w.WriteNil(); + }); + var path = Seed(MeasuredRegistry, typeof(MeasuredDoc), () => new MeasuredDoc { Name = "c" }, privatePayload, "private.cc"); + using (var cache = Open(path, MeasuredRegistry)) + { + AssertSlotRefusal(() => cache.Commit(batch => batch.Remove(Marked)), "AllowPrivate type", nameof(PrivInner), 3, 2); + Assert.That(cache.IsWritable(Clean), Is.True); + } + + var countedPayload = Emit((ref MessagePackWriter w) => + { + w.WriteArrayHeader(5); w.Write("c"); w.WriteNil(); + w.WriteArrayHeader(2); w.Write(0); w.Write(Extra); + w.WriteNil(); w.WriteNil(); + }); + path = Seed(MeasuredRegistry, typeof(MeasuredDoc), () => new MeasuredDoc { Name = "c" }, countedPayload, "counted.cc"); + using (var cache = Open(path, MeasuredRegistry)) + AssertSlotRefusal(() => cache.Commit(batch => batch.Remove(Marked)), "measured through its constructor", nameof(Counted), 2, 1); + + // A resolver that hands out MessagePack's own [Key] formatter exempts nothing. + var relayedPayload = Emit((ref MessagePackWriter w) => + { + w.WriteArrayHeader(8); w.Write("r"); + for (var i = 0; i < 6; i++) w.WriteNil(); + w.WriteArrayHeader(2); w.Write("a"); w.Write(Extra); + }); + path = Seed(MeasuredRegistry, typeof(MeasuredDoc), () => new MeasuredDoc { Name = "c" }, relayedPayload, "relayed.cc"); + using (var cache = Open(path, MeasuredRegistry)) + AssertSlotRefusal(() => cache.Commit(batch => batch.Remove(Marked)), "relayed by a consumer resolver", nameof(Relayed), 2, 1); + } + + // ---- a flatten writes its base chain's decoded view: refused when a record on the chain is not owned ---- + + private static readonly CultRecordKey Base = new("base"); + private static readonly CultRecordKey Var = new("var"); + private static readonly CultRecordKey Fresh = new("fresh"); + + // A store holding plain record base and its variant var, base then given the payload. + private string SeedVariant(byte[]? basePayload, string name) + { + var path = Path.Combine(_directory, name); + using (var cache = Open(path, SurfaceRegistry)) + { + Assert.That(cache.Commit(batch => batch.Upsert(new Surface { Id = "base" }, new CultRecordHandle(Base))), Is.True); + Assert.That(cache.Commit(batch => batch.UpsertVariant(Var, Base, Named(cache, Var))), Is.True); + } + + if (basePayload == null) return path; + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); + snapshot.Records.Single(record => record.Key == Base.Value).Payload = basePayload; + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + return path; + } + + // A variant overrides its name. + private static CultVariantOverride[] Named(CultCache cache, CultRecordKey key) => new[] { cache.Override(nameof(Surface.Id), key.Value) }; + + private static void AssertBaseRefusal(TestDelegate act, string what, bool unreadSlots) + { + var refusal = Assert.Throws(act, what)!; + Assert.That(refusal.RecordKey, Is.EqualTo(Base.Value), $"{what}: the refusal is the base's own"); + Assert.That(refusal.UnreadSlots, unreadSlots ? Is.Not.Null : Is.Null, what); + } + + [Test] + public void AFlattenThroughABaseHeldForUnreadSlotsIsRefusedAndTheVariantStillWritesItsDelta() + { + var nested = VectorFiles.Committed("union-parity", "extra-slot-nested-record.msgpack"); + var path = SeedVariant(nested, "flatten.cc"); + var before = File.ReadAllBytes(path); + using (var cache = Open(path, SurfaceRegistry)) + { + Assert.That(cache.IsWritable(Base), Is.False); + Assert.That(cache.IsWritable(Var), Is.True, "the variant persists only its delta"); + AssertBaseRefusal(() => cache.Commit(batch => batch.Flatten(Var)), "Commit(Flatten)", unreadSlots: true); + AssertBaseRefusal(() => cache.FlattenAsync(Var).GetAwaiter().GetResult(), "FlattenAsync", unreadSlots: true); + AssertBaseRefusal(() => cache.Commit(batch => { batch.UpsertVariant(Fresh, Base, Named(cache, Fresh)); batch.Flatten(Fresh); }), "a new variant flattened as it lands", unreadSlots: true); + Assert.That(File.ReadAllBytes(path), Is.EqualTo(before), "refused flattens leave the store as it was"); + Assert.That(cache.Commit(batch => batch.UpsertVariant(Var, Base, Named(cache, Var))), Is.True, "a variant write persists only its delta"); + Assert.That(cache.Commit(batch => batch.Remove(Var)), Is.True); + } + + var after = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); + Assert.That(after.Records.Single(record => record.Key == Base.Value).Payload, Is.EqualTo(nested), "the base's bytes stay"); + } + + [Test] + public void AFlattenThroughABaseUnderAnUndeclaredDroppingIdIsRefused() + { + var path = Path.Combine(_directory, "drift.cc"); + using (var writer = Open(path, CultDocumentRegistry.ForTypes(new[] { typeof(WideDoc) }))) + Assert.That(writer.Commit(batch => batch.Upsert(new WideDoc { Name = "base", Extra = "kept?" }, new CultRecordHandle(Base))), Is.True); + + using var reader = Open(path, CultDocumentRegistry.ForTypes(new[] { typeof(NarrowDoc) })); + Assert.That(reader.IsWritable(Base), Is.False); + Assert.That(reader.Commit(batch => batch.UpsertVariant(Var, Base, new[] { reader.Override(nameof(NarrowDoc.Name), Var.Value) })), Is.True, "a variant over the unowned base persists only its delta"); + var before = File.ReadAllBytes(path); + AssertBaseRefusal(() => reader.Commit(batch => batch.Flatten(Var)), "flatten over undeclared drift", unreadSlots: false); + Assert.That(File.ReadAllBytes(path), Is.EqualTo(before)); + Assert.That(reader.Commit(batch => batch.Remove(Var)), Is.True); + } + + // ---- IsWritable is the write's own answer ---- + + // False exactly where the write throws. + private static void AssertWriteAgrees(CultCache cache, CultRecordKey key, Action write) + { + var writable = cache.IsWritable(key); + Exception? thrown = null; + try { write(); } + catch (InvalidOperationException exception) { thrown = exception; } + Assert.That(thrown == null, Is.EqualTo(writable), $"IsWritable({key.Value}) is {writable}; the write {(thrown == null ? "landed" : "threw: " + thrown.Message)}"); + } + + [Test] + public void IsWritableIsFalseInAReadOnlyStoreAndAFlattenThereIsNotRefusedForItsCleanBase() + { + var path = SeedVariant(null, "readonly.cc"); + using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = SurfaceRegistry, ReadOnly = true }); + Assert.That(cache.IsWritable(Base), Is.False); + AssertWriteAgrees(cache, Base, () => cache.Commit(batch => batch.Upsert(new Surface { Id = "new" }, new CultRecordHandle(Base)))); + AssertWriteAgrees(cache, Var, () => cache.Commit(batch => batch.Remove(Var))); + var refusal = Assert.Throws(() => cache.Commit(batch => batch.Flatten(Var)))!; + Assert.That(refusal, Is.Not.InstanceOf(), "nothing on the chain is lost: the refusal is the store's read-only one"); + Assert.That(refusal.Message, Does.Contain("read-only backing store")); + } + + // A key the cache does not hold, but a store carries as a foreign record: the store's removal check refuses it. + [Test] + public void IsWritableIsFalseForAnUnheldKeyAStoreListsForeign() + { + var path = Path.Combine(_directory, "foreign.cc"); + using (var writer = Open(path, GapRegistry)) + Assert.That(writer.Commit(batch => batch.Upsert(new GapDoc { Name = "g" }, new CultRecordHandle(Marked))), Is.True); + + using var cache = Open(path, SurfaceRegistry); + Assert.That(cache.GetStored(Marked), Is.Null, "the cache does not hold a record no registered type reads"); + Assert.That(cache.IsWritable(Marked), Is.False); + AssertWriteAgrees(cache, Marked, () => cache.Commit(batch => batch.Remove(Marked))); + Assert.That(cache.IsWritable(Unheld), Is.True); + } + + [Test] + public void IsWritableIsFalseForARecordNoAttachedStoreIsHomeTo() + { + using var cache = new CultCache(CultDocumentRegistry.ForTypes(new[] { typeof(Surface), typeof(GapDoc) })); + Assert.That(cache.Commit(batch => batch.Upsert(new Surface { Id = "memory" }, new CultRecordHandle(Clean))), Is.True); + Assert.That(cache.IsWritable(Clean), Is.True, "a store-less cache writes its own records"); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(Path.Combine(_directory, "gaps.cc")), typeof(GapDoc)); + Assert.That(cache.IsWritable(Clean), Is.False); + AssertWriteAgrees(cache, Clean, () => cache.Commit(batch => batch.Upsert(new Surface { Id = "x" }, new CultRecordHandle(Clean)))); + AssertWriteAgrees(cache, Clean, () => cache.Commit(batch => batch.Remove(Clean))); + Assert.That(cache.IsWritable(Unheld), Is.True, "an unheld key no store lists foreign is writable"); + } + } +} diff --git a/tests/GameCult.Caching.Tests/UnionParityVectorTests.cs b/tests/GameCult.Caching.Tests/UnionParityVectorTests.cs index 9418a680..9265b2ef 100644 --- a/tests/GameCult.Caching.Tests/UnionParityVectorTests.cs +++ b/tests/GameCult.Caching.Tests/UnionParityVectorTests.cs @@ -252,7 +252,7 @@ namespace GameCult.Caching.Tests private static Emit ImageArm(string id, string uri, string alt) => ArmOf(5, Str(id), Str(uri), Nil, Str(alt)); // The constructed value's payload, with a part of the structure replaceable to build a refusal or an extra-slot case. - private static byte[] Surface(Emit? root = null, Emit? outcomes = null, Emit? crafted = null, Extra? extra = null, Emit? trailing = null) + private static byte[] Surface(Emit? root = null, Emit? outcomes = null, Emit? crafted = null, Extra? extra = null, params Emit[] trailing) { extra ??= new Extra(); root ??= BoxArm("0000000000c0", Slots( @@ -264,7 +264,7 @@ namespace GameCult.Caching.Tests outcomes ??= Slots(ArmOf(0, Str("wait"), Str("0000000000a1")), ArmOf(1, Num(7), Str("0000000000a2")), ArmOf(2, Str("0000000000a3"))); crafted ??= ArmOf(1, Str("gear"), Num(4)); var slots = new[] { Str("surface"), Num(3), root, outcomes, ArmOf(1, Str("hammer"), Num(12)), crafted, Nil }; - return Raw(Slots(trailing != null ? slots.Append(trailing).ToArray() : extra.Record ? slots.Append(ExtraSlot).ToArray() : slots)); + return Raw(Slots(trailing.Length > 0 ? slots.Concat(trailing).ToArray() : extra.Record ? slots.Append(ExtraSlot).ToArray() : slots)); } private static byte[] SerializeValue(UnionSurface value) => CultDocumentMessagePackSerialization.SerializeUntyped(value, typeof(UnionSurface), Registry); @@ -338,6 +338,73 @@ namespace GameCult.Caching.Tests [MessagePackObject] public sealed class Floor2 : UnitOutcome { } [MessagePackObject] public sealed class Ceiling2 : UnitOutcome { } + // The document a unit arm is placed in for a store: [Name, Outcome]. + [CultDocument("tests.slot_unit", "tests.slot_unit.v1")] + [MessagePackObject] + public sealed class UnitDoc + { + [Key(0)] [CultName] public string Name = string.Empty; + [Key(1)] public UnitOutcome? Outcome; + } + + // ---- the slot stores: extra-slot-store.cc, extra-slot-consent.cc and their outcomes as data ---- + + // The id extra-slot-consent.cc's records are stored under: UnionSurface's catalog entry with one more member, slot 7. + internal const string WideId = "tests.union_surface.wide"; + + // UnionSurface's members under a new version, listing the wide id compatible: the consent vector's reader. + [CultDocument("tests.union_surface", "tests.union_surface.v2", CompatibleSchemaIds = new[] { WideId })] + [MessagePackObject] + public sealed class SurfaceDeclaring + { + [Key(0)] [CultName] public string Id = string.Empty; + [Key(1)] public int Version; + [Key(2)] public Node? Root; + [Key(3)] public List Outcomes = new(); + [Key(4)] public Item? Part; + [Key(5)] public CraftedItem? Crafted; + [Key(6)] public Node? Missing; + } + + // The registry each slot store is read with. + internal static CultDocumentRegistry SlotReader(string store) => store switch + { + "extra-slot-store.cc" => CultDocumentRegistry.ForTypes(new[] { typeof(UnionSurface), typeof(UnitDoc) }), + "extra-slot-consent.cc" => CultDocumentRegistry.ForTypes(new[] { typeof(SurfaceDeclaring) }), + _ => throw new ArgumentOutOfRangeException(nameof(store), store, null) + }; + + // One row of slot-outcomes.msgpack: what the store's reader holds at the key. The mark fields are null for an unmarked record; + // FlattenRefusedBy is the key whose refusal refuses a Flatten of the record, null when the record is no variant or flattens. + private sealed record SlotOutcome(string Store, string Key, bool Writable, bool? Explained, string? Type, int? Slots, int? Declared, int? Occurrences, string? FlattenRefusedBy = null); + + private static readonly SlotOutcome[] SlotOutcomes = + { + new("extra-slot-store.cc", "marked", false, false, nameof(Composition), 3, 2, 1), + new("extra-slot-store.cc", "clean", true, null, null, null, null, null), + new("extra-slot-store.cc", "unit-arm", false, false, nameof(Denied2), 1, 0, 1), + new("extra-slot-store.cc", "marked-variant", true, null, null, null, null, null, "marked"), + new("extra-slot-consent.cc", "consented", true, true, nameof(SurfaceDeclaring), 8, 7, 1), + new("extra-slot-consent.cc", "over-consent", false, false, nameof(SurfaceDeclaring), 9, 7, 1), + new("extra-slot-consent.cc", "nested", false, false, nameof(SurfaceDeclaring), 8, 7, 2), + new("extra-slot-consent.cc", "clean", true, null, null, null, null, null), + }; + + private static byte[] SlotOutcomesFile() => Raw((ref MessagePackWriter w) => + { + w.WriteArrayHeader(SlotOutcomes.Length); + foreach (var row in SlotOutcomes) + { + w.WriteArrayHeader(9); + w.Write(row.Store); w.Write(row.Key); w.Write(row.Writable); + if (row.Explained is { } explained) w.Write(explained); else w.WriteNil(); + if (row.Type is { } type) w.Write(type); else w.WriteNil(); + foreach (var count in new[] { row.Slots, row.Declared, row.Occurrences }) + if (count is { } value) w.Write(value); else w.WriteNil(); + if (row.FlattenRefusedBy is { } refusedBy) w.Write(refusedBy); else w.WriteNil(); + } + }); + private static MessagePackSerializerOptions UnitOptions => CultDocumentMessagePackSerialization.OptionsFor(typeof(UnionSurface).Assembly); // The reference writes a member-less arm as [key, []]; the other runtimes read these files and write the same bytes. @@ -413,6 +480,79 @@ namespace GameCult.Caching.Tests } } + // A single-file store of the records, each written as its type's value with the store's clock pinned as union-surface.cc's, then + // its payload replaced by bytes no current writer produces; each variant is written over its base (overriding the name to its + // key) before the payloads are replaced; edit changes the snapshot further before it is serialized. + private static byte[] SlotStore(CultDocumentRegistry registry, (string Key, object Value, byte[] Payload)[] records, Action? edit = null, + params (string Key, string Base)[] variants) + { + var work = Path.Combine(Path.GetTempPath(), $"cultlib-unionparity-{Guid.NewGuid():N}"); + Directory.CreateDirectory(work); + try + { + var scratch = Path.Combine(work, "slots.cc"); + using (var cache = CultCacheMessagePack.Create(scratch, new CultCacheOpenOptions { Registry = registry, StoreFlushOnDispose = true })) + { + cache.Clock = () => StoredAt; + foreach (var (key, value, _) in records) + cache.UpsertAsync(value.GetType(), value, new CultRecordKey(key)).GetAwaiter().GetResult(); + foreach (var (key, baseKey) in variants) + Assert.That(cache.Commit(batch => batch.UpsertVariant(new CultRecordKey(key), new CultRecordKey(baseKey), + new[] { cache.Override(typeof(UnionSurface), nameof(UnionSurface.Id), key) })), Is.True); + cache.FlushAsync().GetAwaiter().GetResult(); + } + + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(scratch)); + foreach (var (key, _, payload) in records) + snapshot.Records.Single(record => record.Key == key).Payload = payload; + edit?.Invoke(snapshot); + return CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot); + } + finally + { + Directory.Delete(work, recursive: true); + } + } + + // The store the slot-readonly tests open and the other runtimes read: "marked" holds the extra-slot-nested-record payload, + // "clean" the ordinary value, "unit-arm" a tests.slot_unit document ["unit-arm", [2, ["extra-slot"]]], and "marked-variant" a + // variant of "marked" overriding only its Id: writable, but a flatten of it is refused for its base. + [Test] + public void ExtraSlotStoreVectorHoldsAMarkedACleanAndAUnitArmRecord() + { + Check("extra-slot-store.cc", SlotStore(SlotReader("extra-slot-store.cc"), new (string, object, byte[])[] + { + ("marked", Constructed(), Committed("extra-slot-nested-record.msgpack")), + ("clean", Constructed(), Surface()), + ("unit-arm", new UnitDoc { Name = "unit-arm", Outcome = new Denied2() }, Raw(Slots(Str("unit-arm"), ArmOf(2, ExtraSlot)))), + }, null, ("marked-variant", "marked"))); + } + + // The consent store: every record under the wide id, whose catalog entry is UnionSurface's 7 members plus slot 7. Its reader + // lists the wide id compatible, so the comparison reports slot 7 ignored; only a depth-0 mark within that slot is consented to. + [Test] + public void ExtraSlotConsentVectorHoldsTheConsentCases() + { + Check("extra-slot-consent.cc", SlotStore(Registry, new (string, object, byte[])[] + { + ("consented", Constructed(), Surface(trailing: ExtraSlot)), + ("over-consent", Constructed(), Surface(trailing: new[] { ExtraSlot, ExtraSlot })), + ("nested", Constructed(), Surface(extra: new Extra(Composition: true), trailing: ExtraSlot)), + ("clean", Constructed(), Surface()), + }, snapshot => + { + var entry = snapshot.SchemaCatalog.Single(); + var last = entry.Members.OrderBy(member => member.Slot).Last(); + entry.Members = entry.Members.Append(new CultSchemaMemberCatalogEntry { Slot = 7, MemberName = "Wider", TypeName = last.TypeName }).ToArray(); + entry.SchemaId = WideId; + foreach (var record in snapshot.Records) record.SchemaId = WideId; + })); + } + + // The outcomes of both slot stores as data; SlotReadOnlyTests asserts every row from the committed files. + [Test] + public void SlotOutcomesVectorMatches() => Check("slot-outcomes.msgpack", SlotOutcomesFile()); + // The value names every arm shape the vectors exist to hold other runtimes to. [Test] public void ConstructedValueExercisesEveryArmShape() @@ -475,7 +615,7 @@ namespace GameCult.Caching.Tests else { Assert.That(outcome, Is.EqualTo("read-only"), file); - // The record is read whole; what makes it read-only is the slot rule of the slot-tolerance cut, not asserted here. + // The extra slot is read past; that the record then loads read-only is asserted in SlotReadOnlyTests, store by store. Assert.That(Describe(Decode(payload)), Is.EqualTo(golden), $"{file}: the extra slot is read past"); } } @@ -781,7 +921,8 @@ namespace GameCult.Caching.Tests Assert.That(File.ReadAllBytes(path), Is.EqualTo(new byte[] { 0x91 }), $"switch {off ?? "unset"} rewrites nothing"); File.Delete(path); - foreach (var body in new Action[] { UnionSurfaceVectorMatches, UnionRefusalVectorsRefused, BoundsOutcomesFromCommittedFiles }) + foreach (var body in new Action[] { UnionSurfaceVectorMatches, UnionRefusalVectorsRefused, BoundsOutcomesFromCommittedFiles, + ExtraSlotStoreVectorHoldsAMarkedACleanAndAUnitArmRecord, ExtraSlotConsentVectorHoldsTheConsentCases, SlotOutcomesVectorMatches }) body(); } diff --git a/tests/GameCult.Caching.Tests/VariantTests.cs b/tests/GameCult.Caching.Tests/VariantTests.cs index 17f9ba26..cdbdcf58 100644 --- a/tests/GameCult.Caching.Tests/VariantTests.cs +++ b/tests/GameCult.Caching.Tests/VariantTests.cs @@ -513,7 +513,7 @@ namespace GameCult.Caching.Tests } [Test] - public void AnOverrideOfASlotTheTypeNeverHadIsIgnoredOnLoadAsAPlainRecordIgnoresAnExtraSlot() + public void AnOverrideOfASlotTheTypeNeverHadIsReadPastOnLoadAsAPlainRecordReadsPastAnExtraSlot() { var path = WriteStore(PlainGear("hand-base"), OverridingRecord("hand-variant", "hand-base", CultVariantOverride.Set(99, new byte[] { 0x01 }), @@ -1039,7 +1039,7 @@ namespace GameCult.Caching.Tests } [Test] - public void AnOverrideOfASlotTheTypeNeverHadIsReportedOnEveryLoadAndStaysStoredUntilTheVariantIsWritten() + public void AnOverrideOfASlotTheTypeNeverHadIsReportedOnEveryLoadAndHoldsTheVariantReadOnly() { var path = WriteStore(PlainGear("hand-base"), OverridingRecord("hand-variant", "hand-base", CultVariantOverride.Set(99, new byte[] { 0x01 }), @@ -1059,14 +1059,22 @@ namespace GameCult.Caching.Tests { Assert.That(reopened.BackingStores[0].LastSchemaMigrationReports.SelectMany(report => report.IgnoredExtraSlots), Is.EqualTo(new[] { 99 })); Assert.That(reopened.Get(new CultRecordKey("hand-variant"))!.Name, Is.EqualTo("hand variant")); - reopened.Commit(batch => batch.UpsertVariant(new CultRecordKey("hand-variant"), new CultRecordKey("hand-base"), new[] + var variant = new CultRecordKey("hand-variant"); + Assert.That(reopened.IsWritable(variant), Is.False, "an override of a slot the type lacks, which the catalog did not report, is a mark nothing explains"); + var overrides = new[] { reopened.Override(nameof(VariantGear.Name), "hand variant"), reopened.Override(nameof(VariantGear.Code), "hand-code") - })); + }; + var rewrite = Assert.Throws(() => reopened.Commit(batch => batch.UpsertVariant(variant, new CultRecordKey("hand-base"), overrides)))!; + Assert.That(rewrite.UnreadSlots, Is.Not.Null); + Assert.That(rewrite.UnreadSlots!.TypeName, Is.EqualTo(nameof(VariantGear))); + Assert.That(rewrite.UnreadSlots.SlotCount, Is.EqualTo(100)); + Assert.Throws(() => reopened.Commit(batch => batch.Flatten(variant))); + Assert.Throws(() => reopened.Commit(batch => batch.Remove(variant))); } - Assert.That(OverriddenSlotsOn(path, "hand-variant").OrderBy(slot => slot), Is.EqualTo(new[] { 0, 2 }), "a write of the variant stores only what it was given"); + Assert.That(OverriddenSlotsOn(path, "hand-variant").OrderBy(slot => slot), Is.EqualTo(new[] { 0, 2, 99 }), "the refused writes left the variant as stored"); } private static int[] OverriddenSlotsOn(string path, string key) => diff --git a/tests/vectors/union-parity/README.md b/tests/vectors/union-parity/README.md index c6516fbb..fbf9c6c1 100644 --- a/tests/vectors/union-parity/README.md +++ b/tests/vectors/union-parity/README.md @@ -48,19 +48,93 @@ with one part replaced. ## Extra trailing slot (outcome `read-only`) -Each payload is `union-surface.msgpack` with one slot appended past the reader's last declared slot of one -record (ruling `cultlib-gaps:ruling:extra-trailing-slots`: the record is read, read-only at any depth, every write -of it refused). A reader decodes the value of `union-surface.msgpack` from each; `detail` says where the slot is. +Rulings `cultlib-gaps:ruling:extra-trailing-slots` and `cultlib-gaps:ruling:slot-mark-consent`: a payload carrying a +slot past its reader's declared slots, in any object at any depth, is read, held read-only, and every write or +removal of it is refused before anything is staged. The first four payloads are `union-surface.msgpack` with the +string `"extra-slot"` appended as one more slot of one object; a reader decodes the value of `union-surface.msgpack` +from each. A unit arm with an extra slot is pinned only as a store record, `unit-arm` in `extra-slot-store.cc` +(below): `outcomes.msgpack` keeps the nine rows the leaf walkers count. -| File | detail | Record with the extra slot | -|---|---|---| -| `extra-slot-record.msgpack` | record | the document (slot 7) | -| `extra-slot-nested-record.msgpack` | nested-record | Composition `comp` | -| `extra-slot-arm.msgpack` | arm | the Root Box arm | -| `extra-slot-list-element.msgpack` | list-element | Text `s1`, an element of Use.Slots | +An object's declared slots are the slots its type's own formatter writes for one instance (for these fixtures, +the highest key plus one); a reader derives them from the layout it writes, never from a list of public members. +Only a type the runtime writes by its own positional `[Key]` layout is checked. Exempt, as in the C# reference +(`cultlib-gaps:ruling:leaf-acceptance-follows-reference`): a type the runtime formats some other way (a +user codec or formatter, a hand-written serializer, a resolver ahead of the runtime's own that supplies a layout +of its own, a collection type written as its items; a resolver that hands out the runtime's own positional +layout exempts nothing), and a type no instance of which the runtime can write for measuring +(`cultlib-gaps:ruling:slot-unmeasurable-type`). Neither is ever marked. No vector carries an exempt type: each +runtime pins its own exemption with its own fixture. +Only an array longer than that marks: a short array +(missing trailing slots) keeps the defaults and is writable, and a non-nil value in a retired slot below the highest +key (Image's slot 2) does not mark. A union adds no depth: an arm's array is counted against the arm's own slots. -The C# reference reads these today and its slot-tolerance cut is what makes them read-only; this cut asserts only -that the value is read. +| File | detail | Object with the extra slot | Depth | Mark: type, slots carried, slots declared | +|---|---|---|---|---| +| `extra-slot-record.msgpack` | record | the document (slot 7) | 0 | UnionSurface, 8, 7 | +| `extra-slot-nested-record.msgpack` | nested-record | Composition `comp` | nested | Composition, 3, 2 | +| `extra-slot-arm.msgpack` | arm | the Root Box arm | nested | Box, 3, 2 | +| `extra-slot-list-element.msgpack` | list-element | Text `s1`, an element of Use.Slots | nested | Text, 3, 2 | +| record `unit-arm` of `extra-slot-store.cc` | unit-arm | Denied2 in `[2, ["extra-slot"]]` (union `UnitOutcome`, key 2), slot 1 of the document | 1 | Denied2, 1, 0 | +| `depth-skipped-at-limit.msgpack` (bounds file) | depth | the document (slot 7, a value of nested arrays) | 0 | UnionSurface, 8, 7 | + +Each payload marks exactly one object. Four rows of `outcomes.msgpack` are `read-only` (record, nested-record, arm, +list-element); the fifth `read-only` payload is in `bounds-outcomes.msgpack`, and the unit arm's outcome is its row +in `slot-outcomes.msgpack`. The payload nesting check +runs before the mark: `depth-skipped-past-limit.msgpack` is refused, never marked. + +### What a runtime asserts for a `read-only` payload + +Load it as a store record under the reader's own schema id (the C# reference puts each UnionSurface payload in a +`tests.union_surface` store; the unit arm is already a store record, `unit-arm` of `extra-slot-store.cc`). Then: + +1. the value is read past the extra slot; +2. the record reports the mark as in the table: the first type read with extra slots, the slots it carried and the + slots the reader declares, and the count of objects that carried extra slots (1 here); +3. the record is not writable (C#: `CultCache.IsWritable(key)` false), and a key no store holds is writable; +4. a replacing write, a variant write and a removal of the key, and a flatten of a variant whose base chain holds + the marked record, are refused before anything is staged, + with the runtime's schema conflict refusal carrying the mark; its message names the type and both counts and + never contains `extra-slot`, the slot's value; +5. a write to another key of the same store succeeds. + +Consent: a mark is explained only when it is at depth 0 and the schema comparison of the record's stored id against +the reader reported ignored extra slots covering it: the record is stored under an id the reader lists compatible, +that id's catalog entry has more members, and the mark's slot count is at most the highest reported slot plus one. +Declaring that id consents to an explained mark only. Every mark in the table above is unexplained (stored under +the reader's own id), so each record stays read-only whatever is declared. + +## Slot stores (`slot-outcomes.msgpack`) + +Two single-file stores, each record written as a value and its payload then replaced, the store's clock pinned to +2026-01-01T00:00:00Z as `union-surface.cc`'s: + +- `extra-slot-store.cc`, read with `tests.union_surface` (schema `tests.union_surface.v1`) and `tests.slot_unit` + (schema `tests.slot_unit.v1`, a document `[Name, Outcome: UnitOutcome]`). `marked` is + `extra-slot-nested-record.msgpack`; `clean` is `union-surface.msgpack`; `unit-arm` is the `tests.slot_unit` + document `["unit-arm", [2, ["extra-slot"]]]`, whose Denied2 arm sits at depth 1 (the document is depth 0; a union + adds none); `marked-variant` is a variant of `marked` overriding only `Id` (slot 0) to `"marked-variant"`. +- `extra-slot-consent.cc`: every record is stored under `tests.union_surface.wide`, whose catalog entry is + `tests.union_surface`'s seven members plus slot 7 (`Wider`). Read it with a `tests.union_surface` type of schema + `tests.union_surface.v2` (the reference names it `SurfaceDeclaring`) that lists `tests.union_surface.wide` + compatible, so the comparison reports slot 7 ignored. `consented` is `union-surface.msgpack` plus one root slot + (slot 7); `over-consent` plus two (slots 7 and 8, past the highest reported); `nested` is + `extra-slot-nested-record.msgpack` plus one root slot; `clean` is `union-surface.msgpack`. + +`slot-outcomes.msgpack` is a second outcomes file, as `bounds-outcomes.msgpack` is, so readers of `outcomes.msgpack` +stay green. It is an array of rows `[store, key, writable, explained, type, slots, declared, occurrences, flattenRefusedBy]`, +one per record of both stores: `writable` is what `IsWritable` answers; `flattenRefusedBy` is nil unless the record +is a variant whose flatten is refused, and then it is the key of the base-chain record whose own refusal refuses +it (the refusal names that key; the store is unchanged). For an unmarked record `explained` through `occurrences` +are nil, otherwise they are the mark: whether it is explained, the first type read with extra slots, the slots it carried, +the slots the reader declares, and how many objects carried extra slots. A depth-0 type is the reader's document +type (the reference's names: `SurfaceDeclaring` for the consent store). For each row, open a copy of the store, +assert the mark and `IsWritable`, flatten the key when `flattenRefusedBy` is set and assert the refusal, then +remove the key: refused (with the mark, the file unchanged) exactly where +`writable` is false. In the consent store a write of `consented` lands and a write of `over-consent` is refused. + +The carried-verbatim check: open `extra-slot-store.cc`, assert `marked` read-only and `clean` writable, write +`clean` and flush: `marked`'s payload bytes, schema id and stored-at, and every catalog entry's bytes, equal the +originals. ## Bounds (`bounds-outcomes.msgpack`) diff --git a/tests/vectors/union-parity/extra-slot-consent.cc b/tests/vectors/union-parity/extra-slot-consent.cc new file mode 100644 index 0000000000000000000000000000000000000000..54ed4d954290e45dce66044d4099e1fc40c73d09 GIT binary patch literal 3479 zcmeH}&2G~`5XbvTFTHYyT+_PtZtTPnLP9xIk)WWqlmkL&y&gA~9J{hNZ6nloph={L z8y{%{s9Zoo9HSy0B0LR~*ma#!kkZhILlaAhcV}jQGydz{eW5uEW7NP+Ntkhbam z!8ZYc2&9T@d+zg~;#-V^v}2p_<${T?7mP{UN_ROZF`{aDL!lhMYR067!Zc#(L^ZPp zrI0f$s?sdbtfc`l%$&v*4UGsWaxSM6rpuJFpR@BXQUL;%*$OK|DxbPw!PG)zvH^a8 z)E8SJ78;hJY@k&t{}kgoo=D~8g_IkHE}Cc{s$0Ef)~bPkva}|A+ug)rJ6Ht^d~i9= zTFeOmV==cH-w5V!f>=wSx)sMkN7sbidydU;&1fVDQxSdz_7+Ytlo;bQi}Nf+S5R^_ zDE)-aSV+vW>S2jukY%h>0VN|9P%~H;X}vF%$33Y$V!kb^8^BRW{o(YAXTn%;X^i`@ zBDhzE;0!r>js?$g5Dhs4X&GGbZ7$un194}#l%n`|$C6eiP77;$>8DUc8IJ{5jd2S; zvjikI({M$O+{Xs4#o!O@APBd6)CGnEziPoh+uM&DE9gv9HBd9ekaK`NJfj%8rDx0> zR~2e-;HGKFOa`JdqGYM5>xQgkEt4?LRjM_c6K{*=r-3$ia?<(UX->AI26P-v_WKUb zophR0tzJXulHuzT`cJ<<-J#u$tJ#_1Iy0@_mKm0_r?}96wjGJEs}GlILc3w48|_F` z!@~5$6o!~Pe1fO1f9U{?#vylrxqm#^ySIruB5BVYWyt!W6`qOAe6Q)PC}wqQOy#mCNAI z!1F5j7RzB9Ak7qSx>336Xssc9$L zI^fyqXe-?Z=D1DAbrhu@xImymg)hODtPucsz715^4rRC^AOOjQ&$y!khuYNE)N`5V zxxX5nn$$|8wt3WvL-XR=*gBx4FXk~4P_DSgK`4)M+tIe|QbmG5Q!1zs(k4uZh6}Fi z+LRz7q8ij=0ToKTTIqgMPce`d+=rH~H|y7&)z^#VU7e(1JN~-_!a<>yZuuxtZ+?OH z5P7{x*6VR@a$M21#g+cD2L|Pp0qUw7D{|0Nj)4kH0)VDf2sTEoEo5{&-J*p4#<`i{T%`vz0*+ z%t*ZUKA@$G;Z^Bt0j}d}$7VihXk>^@eGv#-HKaqiFSS(4CqOiFfKiVtA$&r;KrxWg z;qGsF^?aqx9m;&*SG?FW1(xmDaJCpId2N@Z zWF;vdw6R;1KxV7N`)xI5^TMcD!sR90f-~tA{k1_+gtMN$mDbBmP!a%bpjj_#;U-TbY`mPxf^bwX@Z3Rg7hQyv{wIR#qgNR#x=; z!)&crQB-WuNy6s6docVFB6H1|rv32+b~tTTvu~%3NM0E6hv!|C?SBoSsW<_OFy5|C zux$0DI_4!cX|0Nec=|hvi#yA0QR$9LM zB~Q!70lpSMY?hgxm$j@Yij!XLB&(gDUjx&7hSyND0ZFTbHSdeVe_-&-ceBzjmUbm| Fe*rLCGo1hc literal 0 HcmV?d00001 diff --git a/tests/vectors/union-parity/slot-outcomes.msgpack b/tests/vectors/union-parity/slot-outcomes.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..cd9a9ef87c49baebd83bc9a95545d34efe129288 GIT binary patch literal 378 zcmZ{gK@Ng25Jd$S;9=^*E65EzK~qN42s5S~h)0nFh#HMCG44sZm?$hXz^rER-~T`V zP&{GJMhcq{3(I6+u2h*yvxV5X>ypwPSU-6o`lu3+SXbX0RGc%^ETl=DJ4JvR z;)OREal5zKB9}%>l`P0%oRqa2Lr YVCQ=1Y#c2SEPod2L1EyJ<^2uy2CZqM{r~^~ literal 0 HcmV?d00001 diff --git a/unity/org.gamecult.cultlib/CHANGELOG.md b/unity/org.gamecult.cultlib/CHANGELOG.md index 225bb3ae..a2a1efae 100644 --- a/unity/org.gamecult.cultlib/CHANGELOG.md +++ b/unity/org.gamecult.cultlib/CHANGELOG.md @@ -7,6 +7,15 @@ All notable changes to this package are documented in this file. ### Breaking - A single-file store holding two records under one key is unreadable: the reader refuses it with `CultStoreUnreadableException` naming the key (keys compare by ordinal code units, no case folding or normalisation), so a cache, `ReadStoreSnapshot` and a flush refuse the file and leave it as it is. Before, the later record silently replaced the earlier. A directory store reads its manifest through the same reader and refuses a duplicate key the same way. Repair the duplicate with the runtime that wrote it before reading the store here. +- A record whose payload carries a slot past the slots its reading type declares, at any depth (the record, a nested object, a list element, a union arm or a unit arm), is loaded read-only: it is served, and every staged write or removal of its key (`Upsert`, `UpsertVariant`, `Remove`, a batch, and `Flatten` of a variant) is refused with `CultSchemaConflictException` before anything is staged, and the store keeps the record byte for byte. Before, the extra slots were skipped on read and dropped on the next write. Declaring an id compatible consents only to depth-0 slots the schema comparison reports ignored under that id; any other extra slot keeps the record read-only whatever is declared, including under the reader's own id. A variant overriding a slot its type does not have is read-only too (it used to be shed on flush). Write such a record from the build that declares those slots. Missing trailing slots still default and stay writable. +- `Flatten` (and `FlattenAsync`) of a variant whose base chain holds a record its store does not own (a record held read-only for extra slots, or undeclared drift that drops slots) is refused with `CultSchemaConflictException` naming that base record, and the store is left as it was. Before, the flatten wrote the base's decoded view into a plain record and the base's unread data was lost for that variant. Writing and removing the variant itself are still accepted: they persist only its delta. +- `MintElementIds` refuses a home store's commit while that store holds a record read-only for extra slots whose element ids exist only in memory, until the build that declares those slots rewrites it; commits to earlier stores stand. +- A type's declared slots are the array its own formatter writes for one instance, not its public `[Key]` members, so a non-public `[Key]` of an `AllowPrivate` type counts. Only a `[MessagePackObject]` type MessagePack writes by its `[Key]` layout is checked for extra slots. A type with another formatter (its own `[MessagePackFormatter]`, a formatter of its own from a `CultCacheFormatterResolver`, or the collection formatter of a `[MessagePackObject]` collection type) owns its layout and is not checked; a resolver that hands out MessagePack's own `[Key]`-layout formatter does not exempt the type; a type no instance of which can be written is not checked either (no parameterless constructor and a `[Key]` getter that throws on an uninitialized instance; ruled `cultlib-gaps:ruling:slot-unmeasurable-type`). Extra slots in those are read past and dropped on the next write, as before. Measuring runs the type's `[Key]` getters, and on failure its parameterless constructor, once per type per process on the first store read of it; a failure leaves the type unchecked. + +### Added + +- `CultCache.IsWritable(CultRecordKey)`: whether a removal of the key, or a write of the record's own type, would be let through, answered by the write's own decision: false for a foreign record, undeclared drift that drops slots, unread slots, a read-only home store, or a record no attached store is home to; true for a key the cache does not hold unless a store lists it foreign. It does not predict a `Flatten` refused for its base chain. Live and advisory: the write still decides. +- `CultUnreadSlots` (`TypeName`, `SlotCount`, `DeclaredCount`, `Occurrences`, `Explained`), `CultStoredDocument.UnreadSlots`, and `CultSchemaConflictException.UnreadSlots` with its constructor overload: what a load found past the reader's slots, holding no slot value. The refusal message names the type and both counts, never a value. ## [3.0.0] - 2026-11-11 From 5e10909fe9da3c9e2f5f329b39251eff877e69db Mon Sep 17 00:00:00 2001 From: Libby Date: Thu, 12 Nov 2026 04:01:36 +0000 Subject: [PATCH 45/93] Bytes after the value: a payload is one MessagePack value (C#) (#125) --- .../cultmesh/content-acceptance-vectors.json | 18 +- packages/cultnet-ts/CHANGELOG.md | 4 + packages/cultnet-ts/README.md | 4 +- packages/cultnet-ts/src/content.ts | 4 +- .../CultPayloadDepth.cs | 13 +- .../Contracts/cultcache-persistence-format.md | 2 + .../CultMeshProviderSessionWire.cs | 5 +- .../CultNetDocumentRegistry.cs | 12 +- .../BytesAfterVectorTests.cs | 211 ++++++++++++++++++ .../PayloadDepthTests.cs | 21 +- .../BytesAfterPathTests.cs | 61 +++++ .../CultMeshContentVectorTests.cs | 6 +- .../CultMeshProviderSessionWireTests.cs | 27 +++ .../BytesAfterPathTests.cs | 62 +++++ tests/vectors/union-parity/README.md | 19 ++ .../bytes-after-deep-tail.msgpack | Bin 0 -> 601 bytes .../bytes-after-never-used.msgpack | Bin 0 -> 279 bytes .../union-parity/bytes-after-nil.msgpack | Bin 0 -> 278 bytes .../union-parity/bytes-after-outcomes.msgpack | 1 + .../bytes-after-second-document.msgpack | Bin 0 -> 554 bytes unity/org.gamecult.cultlib/CHANGELOG.md | 1 + 21 files changed, 441 insertions(+), 30 deletions(-) create mode 100644 tests/GameCult.Caching.Tests/BytesAfterVectorTests.cs create mode 100644 tests/GameCult.Mesh.Tests/BytesAfterPathTests.cs create mode 100644 tests/GameCult.Networking.Tests/BytesAfterPathTests.cs create mode 100644 tests/vectors/union-parity/bytes-after-deep-tail.msgpack create mode 100644 tests/vectors/union-parity/bytes-after-never-used.msgpack create mode 100644 tests/vectors/union-parity/bytes-after-nil.msgpack create mode 100644 tests/vectors/union-parity/bytes-after-outcomes.msgpack create mode 100644 tests/vectors/union-parity/bytes-after-second-document.msgpack diff --git a/contracts/cultmesh/content-acceptance-vectors.json b/contracts/cultmesh/content-acceptance-vectors.json index d9868a63..b4d92cde 100644 --- a/contracts/cultmesh/content-acceptance-vectors.json +++ b/contracts/cultmesh/content-acceptance-vectors.json @@ -899,23 +899,23 @@ { "label": "trailing_nil", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdsA=", - "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", - "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + "accepted": false, + "reencodedBase64": null, + "recordKey": null }, { "label": "trailing_reserved_code", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdsE=", - "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", - "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + "accepted": false, + "reencodedBase64": null, + "recordKey": null }, { "label": "trailing_truncated_array", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdpI=", - "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", - "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + "accepted": false, + "reencodedBase64": null, + "recordKey": null }, { "label": "top_nil", diff --git a/packages/cultnet-ts/CHANGELOG.md b/packages/cultnet-ts/CHANGELOG.md index 56a15c7b..cd5a32f2 100644 --- a/packages/cultnet-ts/CHANGELOG.md +++ b/packages/cultnet-ts/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to `@gamecult/cultnet-ts` are recorded here, following ## Unreleased +### Breaking + +- `decodeManifest` refuses a manifest with bytes after its first MessagePack value (`CDN artifact manifest has N bytes after its first MessagePack value.`), as the C# reference does; before, the tail was ignored. `contracts/cultmesh/content-acceptance-vectors.json` now holds `trailing_nil`, `trailing_reserved_code` and `trailing_truncated_array` as refused. + ### Added - Fork card (`contracts/cultnet/governance/fork-card.md`): `raised_at`, a required key holding an RFC3339 instant string or nil (the question's first admission time), outside `options_hash`; the card vectors are re-pinned with the key, three negatives (epoch integer, date-only string, missing key) and a nil-`raised_at` valid card; every `optionsHash` digest is unchanged. diff --git a/packages/cultnet-ts/README.md b/packages/cultnet-ts/README.md index 7d1b9a6a..92231d52 100644 --- a/packages/cultnet-ts/README.md +++ b/packages/cultnet-ts/README.md @@ -144,8 +144,8 @@ imports no `node:` module, so a browser bundle can take it. past the shared depth limit are refused. - The decoder reads what MessagePack-CSharp reads and refuses what it refuses: nil in a string, tag or metadata slot is `null` and is written back as nil, - a missing trailing slot takes the reference's default, extra slots and trailing - bytes are skipped (a chunk reference's fifth slot is its element id, a string + a missing trailing slot takes the reference's default, extra slots are skipped and bytes + after the manifest's first value are refused (a chunk reference's fifth slot is its element id, a string or nil, `""` when absent; `packContent` writes the decimal offset as the id, as the reference does), a float-encoded integer is refused, and invalid UTF-8 decodes to U+FFFD. A nil `chunks` is refused, as the reference's diff --git a/packages/cultnet-ts/src/content.ts b/packages/cultnet-ts/src/content.ts index 6f9fd873..683de8a0 100644 --- a/packages/cultnet-ts/src/content.ts +++ b/packages/cultnet-ts/src/content.ts @@ -249,7 +249,7 @@ class Reader { export function decodeManifest(bytes: Uint8Array): CultMeshCdnArtifactManifest { checkPayloadDepth(bytes, "CDN artifact manifest"); const reader = new Reader(bytes); - // A slot the array does not reach keeps the property initialiser; a slot past the tenth is skipped. Trailing bytes are not read. + // A slot the array does not reach keeps the property initialiser; a slot past the tenth is skipped. const count = reader.array("manifest"); let index = 0; const slot = (read: () => T, initial: T): T => index++ < count ? read() : initial; @@ -267,6 +267,8 @@ export function decodeManifest(bytes: Uint8Array): CultMeshCdnArtifactManifest { [reader.string("metadata") ?? wrongType("metadata"), reader.string("metadata")] as const), []), }; for (; index < count; index += 1) reader.skip(); + // A manifest is one record payload: exactly one MessagePack value, as the reference's one-value rule has it. + if (reader.position < bytes.length) throw new Error(`CDN artifact manifest has ${bytes.length - reader.position} bytes after its first MessagePack value.`); validateManifest(manifest); return manifest; } diff --git a/src/GameCult.Caching.MessagePack/CultPayloadDepth.cs b/src/GameCult.Caching.MessagePack/CultPayloadDepth.cs index 258663e8..6179f8be 100644 --- a/src/GameCult.Caching.MessagePack/CultPayloadDepth.cs +++ b/src/GameCult.Caching.MessagePack/CultPayloadDepth.cs @@ -7,11 +7,12 @@ namespace GameCult.Caching.MessagePack; internal enum PayloadDirection { Read, Write } /// -/// The one payload nesting count. Walks the MessagePack headers of a document payload iteratively, counting every array and +/// The one walk of a payload's shape: its depth and its one value. Walks the MessagePack headers of a document payload iteratively, counting every array and /// map (read by a formatter or skipped past it; the outermost array is depth 1) and refusing the first container past /// . Scalars are skipped by length, nothing is allocated per /// element, and the count stack is bounded by the limit. A refusal names the type, the depth reached and the limit, -/// never payload bytes. A payload that ends early is the deserializer's to refuse. +/// never payload bytes. A payload that ends early is the deserializer's to refuse; bytes after the first value are refused +/// with their count and never walked. /// internal static class CultPayloadDepth { @@ -73,7 +74,13 @@ internal static class CultPayloadDepth // One element is complete; close every container it was the last element of. while (depth > 0 && --remaining[depth - 1] == 0) depth--; - if (depth == 0) return; + if (depth == 0) + { + if (position < bytes.Length) + throw new MessagePackSerializationException( + $"{type.FullName} payload has {bytes.Length - position} bytes after its first MessagePack value (refused on {(direction == PayloadDirection.Read ? "read" : "write")})."); + return; + } } } diff --git a/src/GameCult.Caching/Contracts/cultcache-persistence-format.md b/src/GameCult.Caching/Contracts/cultcache-persistence-format.md index 5ac6c3b5..ad3c06f5 100644 --- a/src/GameCult.Caching/Contracts/cultcache-persistence-format.md +++ b/src/GameCult.Caching/Contracts/cultcache-persistence-format.md @@ -403,6 +403,8 @@ runtime enforces the same bound on read and on write, and the refusal names the the depth and the limit, never payload bytes. The vectors are in `tests/vectors/union-parity` (`bounds-outcomes.msgpack`). +A record's payload is exactly one MessagePack value: every runtime refuses a payload with bytes after its first value wherever it reads a payload under the nesting bound, and on write where a write takes bytes the library did not encode itself (a C# type's own formatter, a Rust raw envelope), without walking those bytes, so a tail past the limit is refused as bytes after the value and not as depth; the refusal names the type and the count of bytes after the value, never the bytes (vectors: `tests/vectors/union-parity/bytes-after-outcomes.msgpack`). A CDN manifest is stored and keyed as a record and follows the same rule (`contracts/cultmesh/content-acceptance-vectors.json`: `trailing_nil`, `trailing_reserved_code` and `trailing_truncated_array` are refused). Streams of values (CultNet frames, a store's record array) are different contracts and are not covered. + ## Variant Records A variant record is a record whose document is a base record's document with some diff --git a/src/GameCult.Mesh/CultMeshProviderSessionWire.cs b/src/GameCult.Mesh/CultMeshProviderSessionWire.cs index 03f3a70c..ad9ae66e 100644 --- a/src/GameCult.Mesh/CultMeshProviderSessionWire.cs +++ b/src/GameCult.Mesh/CultMeshProviderSessionWire.cs @@ -284,7 +284,10 @@ namespace GameCult.Mesh throw new ArgumentException($"Expected provider command schema '{CultMeshProviderSessionWireContract.CommandSchema}'.", nameof(document)); if (!string.Equals(document.PayloadEncoding, "messagepack", StringComparison.Ordinal)) throw new ArgumentException("Provider command document must use MessagePack.", nameof(document)); - var command = MessagePackSerializer.Deserialize(document.Payload, Options); + // A raw record's payload is one MessagePack value (ruling trailing-bytes-refuse): a tail is refused by count, never walked. + var command = MessagePackSerializer.Deserialize(new ReadOnlyMemory(document.Payload), Options, out var read); + if (read < document.Payload.Length) + throw new MessagePackSerializationException($"Provider command document: {document.Payload.Length - read} bytes after its first MessagePack value (refused on read)."); if (command == null) throw new MessagePackSerializationException("Provider command decoded to null."); Validate(command); return command; diff --git a/src/GameCult.Networking/CultNetDocumentRegistry.cs b/src/GameCult.Networking/CultNetDocumentRegistry.cs index 19f17c8c..bf17a91f 100644 --- a/src/GameCult.Networking/CultNetDocumentRegistry.cs +++ b/src/GameCult.Networking/CultNetDocumentRegistry.cs @@ -641,23 +641,19 @@ namespace GameCult.Networking string storedAt, CultNetDocumentMessageOptions? options = null) { - var binding = GetByDocumentType(descriptor.DocumentType) ?? - new CultNetDocumentBinding( - descriptor.DocumentType, - descriptor.SchemaId, - value => CultDocumentMessagePackSerialization.SerializeUntyped(value, value.GetType(), _documents), - payload => CultDocumentMessagePackSerialization.DeserializeUntyped(descriptor.DocumentType, payload, _documents)); + var binding = GetByDocumentType(descriptor.DocumentType); return new CultNetRawDocumentRecord { - SchemaId = binding.SchemaId, + SchemaId = binding?.SchemaId ?? descriptor.SchemaId, SchemaName = descriptor.SchemaName, SchemaVersion = descriptor.SchemaVersion, SchemaContentHash = descriptor.ContentHash, RecordKey = key.Value, StoredAt = storedAt, PayloadEncoding = "messagepack", - Payload = binding.PayloadSerializer(document), + Payload = binding?.PayloadSerializer(document) ?? + CultDocumentMessagePackSerialization.SerializeUntyped(document, document.GetType(), _documents), SourceRuntimeId = options?.SourceRuntimeId, SourceAgentId = options?.SourceAgentId, SourceRole = options?.SourceRole, diff --git a/tests/GameCult.Caching.Tests/BytesAfterVectorTests.cs b/tests/GameCult.Caching.Tests/BytesAfterVectorTests.cs new file mode 100644 index 00000000..86d677e6 --- /dev/null +++ b/tests/GameCult.Caching.Tests/BytesAfterVectorTests.cs @@ -0,0 +1,211 @@ +#nullable enable +using System; +using System.IO; +using System.Linq; +using GameCult.Caching.MessagePack; +using MessagePack; +using NUnit.Framework; +using UnionSurface = GameCult.Caching.Tests.UnionParityVectorTests.UnionSurface; + +namespace GameCult.Caching.Tests +{ + // A record's payload is exactly one MessagePack value. The vectors are union-surface.msgpack followed by a tail; the C# + // reference is their only writer (VectorFiles, CULTCACHE_WRITE_VECTORS=1) and every other runtime is held to bytes-after-outcomes.msgpack. + public class BytesAfterVectorTests + { + private const string Vector = "union-parity"; + private const string Canary = "CANARY-bytes-after-7d1e"; + private const string Clause = "bytes after its first MessagePack value"; + private const int Limit = CultMessagePackSecurity.MaximumPayloadDepth; + + private static readonly CultDocumentRegistry Registry = CultDocumentRegistry.ForTypes(new[] { typeof(UnionSurface) }); + + private static byte[] Base => VectorFiles.Committed(Vector, "union-surface.msgpack"); + + private static byte[] DeepTail() + { + var text = System.Text.Encoding.ASCII.GetBytes(Canary); + return Enumerable.Repeat((byte)0x91, 300).Concat(new[] { (byte)(0xa0 | text.Length) }).Concat(text).ToArray(); + } + + // file, tail + private static (string File, byte[] Tail)[] Cases() => new (string, byte[])[] + { + ("bytes-after-nil.msgpack", new byte[] { 0xc0 }), + ("bytes-after-never-used.msgpack", new byte[] { 0xc1, 0xc1 }), + ("bytes-after-deep-tail.msgpack", DeepTail()), + ("bytes-after-second-document.msgpack", Base), + }; + + // The bounds row shape: [file, outcome, union, key|nil, detail]. + private static byte[] OutcomesFile() + { + var buffer = new System.Buffers.ArrayBufferWriter(); + var w = new MessagePackWriter(buffer); + var cases = Cases(); + w.WriteArrayHeader(cases.Length); + foreach (var (file, _) in cases) + { + w.WriteArrayHeader(5); + w.Write(file); + w.Write("refused"); + w.Write("UnionSurface"); + w.WriteNil(); + w.Write("bytes-after"); + } + + w.Flush(); + return buffer.WrittenSpan.ToArray(); + } + + private static string Message(Action action) + { + try { action(); } + catch (Exception exception) + { + var message = ""; + for (var e = exception; e != null; e = e.InnerException) message += e.Message + "\n"; + return message; + } + + throw new AssertionException("the payload was accepted"); + } + + [Test] + public void VectorsMatchAndRefuse() + { + var baseBytes = Base; + foreach (var (file, tail) in Cases()) VectorFiles.Check(Vector, file, baseBytes.Concat(tail).ToArray()); + VectorFiles.Check(Vector, "bytes-after-outcomes.msgpack", OutcomesFile()); + + // From here only the committed files speak. + var reader = new MessagePackReader(VectorFiles.Committed(Vector, "bytes-after-outcomes.msgpack")); + Assert.That(reader.ReadArrayHeader(), Is.EqualTo(4)); + for (var i = 0; i < 4; i++) + { + Assert.That(reader.ReadArrayHeader(), Is.EqualTo(5)); + var file = reader.ReadString()!; + Assert.That(reader.ReadString(), Is.EqualTo("refused")); + Assert.That(reader.ReadString(), Is.EqualTo("UnionSurface")); + Assert.That(reader.TryReadNil(), Is.True); + Assert.That(reader.ReadString(), Is.EqualTo("bytes-after")); + + var payload = VectorFiles.Committed(Vector, file); + Assert.That(payload.Take(baseBytes.Length), Is.EqualTo(baseBytes), $"{file} begins with union-surface.msgpack"); + var count = payload.Length - baseBytes.Length; + var message = Message(() => CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(UnionSurface), payload, Registry)); + Assert.That(message, Does.Contain(typeof(UnionSurface).FullName).And.Contain($"{count} {Clause}").And.Contain("refused on read"), file); + Assert.That(message, Does.Not.Contain(Canary).And.Not.Contain("the limit is"), file); + } + } + + // The codec the store and the inspector read through is a path of its own: DecodeDocument deserializes by the codec's + // delegate, so a tail it did not refuse would be read as a document. + [Test] + public void CodecDecodeDocumentRefusesATail() + { + var codec = CultCacheMessagePack.CreateCodec(Registry); + Assert.That(codec.DecodeDocument(new CultRecordKey("surface"), Base), Is.Not.Null); + var message = Message(() => codec.DecodeDocument(new CultRecordKey("surface"), Base.Concat(new byte[] { 0xc0 }).ToArray())); + Assert.That(message, Does.Contain(typeof(UnionSurface).FullName).And.Contain($"1 {Clause}").And.Contain("refused on read")); + } + + [Test] + public void StoreLoadRefuses() + { + var work = Path.Combine(Path.GetTempPath(), $"cultlib-bytesafter-{Guid.NewGuid():N}"); + Directory.CreateDirectory(work); + try + { + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(VectorFiles.Committed(Vector, "union-surface.cc")); + snapshot.Records[0].Payload = Base.Concat(new byte[] { 0xc0 }).ToArray(); + var scratch = Path.Combine(work, "tail.cc"); + File.WriteAllBytes(scratch, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + var load = Message(() => CultCacheMessagePack.Create(scratch, new CultCacheOpenOptions { Registry = Registry }).Dispose()); + Assert.That(load, Does.Contain("surface").And.Contain($"1 {Clause}")); + } + finally + { + Directory.Delete(work, recursive: true); + } + } + + // A directory store names a page by the SHA-256 of its bytes and its manifest commits to that hash: the page holding the + // payload is rewritten with a tail, renamed, and the manifest told its new hash, so the store is well formed and only the payload is wrong. + [Test] + public async System.Threading.Tasks.Task DirectoryLoadPageRefuses() + { + var folder = Path.Combine(Path.GetTempPath(), $"cultlib-bytesafter-dir-{Guid.NewGuid():N}"); + Directory.CreateDirectory(folder); + try + { + var path = Path.Combine(folder, "tail.cc"); + var options = new CultCacheOpenOptions { Registry = Registry, UseDirectoryStore = true, StoreFlushOnDispose = true }; + using (var writer = CultCacheMessagePack.Create(path, options)) + { + var value = CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(UnionSurface), Base, Registry); + await writer.UpsertAsync(typeof(UnionSurface), value, new CultRecordKey("surface")); + await writer.FlushAsync(); + } + + var rehashed = new System.Collections.Generic.List<(byte[] Old, byte[] New)>(); + foreach (var file in Directory.EnumerateFiles(folder, "*.msgpack", SearchOption.AllDirectories).ToArray()) + { + var bytes = File.ReadAllBytes(file); + if (bytes.AsSpan().IndexOf(Base) < 0) continue; + var page = CultDocumentMessagePackSerialization.DeserializePersistedRecord(bytes); + page.Payload = Base.Concat(new byte[] { 0xc0 }).ToArray(); + var forged = CultDocumentMessagePackSerialization.SerializePersistedRecord(page); + File.Delete(file); + File.WriteAllBytes(Path.Combine(Path.GetDirectoryName(file)!, Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(forged)).ToLowerInvariant() + ".msgpack"), forged); + rehashed.Add((System.Security.Cryptography.SHA256.HashData(bytes), System.Security.Cryptography.SHA256.HashData(forged))); + } + + Assert.That(rehashed, Is.Not.Empty, "the record page was found"); + foreach (var file in Directory.EnumerateFiles(folder, "*", SearchOption.AllDirectories).Where(f => !f.EndsWith(".msgpack", StringComparison.Ordinal)).ToArray()) + { + var bytes = File.ReadAllBytes(file); + foreach (var (old, @new) in rehashed) + for (var at = bytes.AsSpan().IndexOf(old); at >= 0; at = bytes.AsSpan().IndexOf(old)) @new.CopyTo(bytes, at); + File.WriteAllBytes(file, bytes); + } + + var thrown = Assert.CatchAsync(async () => { using var cache = await CultCacheMessagePack.OpenAsync(path, new CultCacheOpenOptions { Registry = Registry, UseDirectoryStore = true, ReadOnly = true }); }); + var chain = ""; + for (var e = thrown; e != null; e = e.InnerException) chain += e.Message + "\n"; + Assert.That(chain, Does.Contain($"1 {Clause}")); + } + finally + { + Directory.Delete(folder, recursive: true); + } + } + + public sealed class TwoValueFormatter : global::MessagePack.Formatters.IMessagePackFormatter + { + public void Serialize(ref MessagePackWriter writer, TwoValues value, MessagePackSerializerOptions options) + { + writer.WriteArrayHeader(0); + writer.Write(7); + } + + public TwoValues Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) { reader.Skip(); return new TwoValues(); } + } + + [CultDocument("tests.two_values", "tests.two_values.v1")] + [MessagePackObject] + [MessagePackFormatter(typeof(TwoValueFormatter))] + public sealed class TwoValues + { + [Key(0)] [CultName] public string Id = string.Empty; + } + + [Test] + public void OwnFormatterWritingTwoValuesRefusedOnWrite() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(TwoValues) }); + var message = Message(() => CultDocumentMessagePackSerialization.SerializeUntyped(new TwoValues(), typeof(TwoValues), registry)); + Assert.That(message, Does.Contain(typeof(TwoValues).FullName).And.Contain($"1 {Clause}").And.Contain("refused on write")); + } + } +} diff --git a/tests/GameCult.Caching.Tests/PayloadDepthTests.cs b/tests/GameCult.Caching.Tests/PayloadDepthTests.cs index 91aa9a6f..1d3db1d0 100644 --- a/tests/GameCult.Caching.Tests/PayloadDepthTests.cs +++ b/tests/GameCult.Caching.Tests/PayloadDepthTests.cs @@ -148,10 +148,25 @@ namespace GameCult.Caching.Tests } [Test] - public void OnlyTheFirstValueIsThePayload() + public void OnlyOneValueIsAPayload() { - Accepts(Cat(Bytes(0x90), Chain(Limit + 10)), "bytes after the first value are not walked"); - Accepts(Cat(Bytes(0x01), Chain(Limit + 10)), "nor after a scalar"); + var tails = new[] + { + (Cat(Bytes(0x90), Chain(Limit + 10)), Chain(Limit + 10).Length), + (Cat(Bytes(0x01), Chain(Limit + 10)), Chain(Limit + 10).Length), + (Bytes(0x90, 0xc1, 0xc1), 2), + }; + foreach (var (payload, count) in tails) + foreach (var direction in new[] { PayloadDirection.Read, PayloadDirection.Write }) + { + var message = Assert.Throws(() => CultPayloadDepth.Check(payload, typeof(PayloadDepthTests), direction))!.Message; + var word = direction == PayloadDirection.Read ? "read" : "write"; + Assert.That(message, Is.EqualTo($"{typeof(PayloadDepthTests).FullName} payload has {count} bytes after its first MessagePack value (refused on {word}).")); + } + + // The tail is not walked: a tail nested past the limit is refused as bytes after the value, not as depth. + var deep = Assert.Throws(() => CultPayloadDepth.Check(Cat(Bytes(0x90), Chain(Limit + 10)), typeof(object), PayloadDirection.Read))!; + Assert.That(deep.Message, Does.Not.Contain("the limit is")); } } } diff --git a/tests/GameCult.Mesh.Tests/BytesAfterPathTests.cs b/tests/GameCult.Mesh.Tests/BytesAfterPathTests.cs new file mode 100644 index 00000000..ed9b904b --- /dev/null +++ b/tests/GameCult.Mesh.Tests/BytesAfterPathTests.cs @@ -0,0 +1,61 @@ +using System; +using System.Linq; +using System.Reflection; +using FluentAssertions; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using GameCult.Networking; +using NUnit.Framework; + +namespace GameCult.Mesh.Tests; + +// The mesh's typed reads of a raw record decode the payload through the one-value rule: a tail is refused, never ignored. +public class BytesAfterPathTests +{ + private const string Clause = "bytes after its first MessagePack value"; + + private static CultNetRawDocumentRecord WithTail(CultMeshCdnArtifactManifest manifest) + { + var payload = CultDocumentMessagePackSerialization.SerializeUntyped(manifest, typeof(CultMeshCdnArtifactManifest)).Concat(new byte[] { 0xc0 }).ToArray(); + return new CultNetRawDocumentRecord + { + SchemaId = CultDocumentRegistry.Shared.GetRequired().SchemaId, + RecordKey = "k", + StoredAt = "1970-01-01T00:00:00.000Z", + Payload = payload, + }; + } + + private static CultMeshCdnArtifactManifest Manifest() => new() { ArtifactId = "a", Version = "1", ContentHash = "h" }; + + private static string Refusal(Action read) + { + var thrown = Assert.Catch(read)!; + return thrown is TargetInvocationException { InnerException: { } inner } ? inner.ToString() : thrown.ToString(); + } + + [Test] + public void ASnapshotDecodeThroughABindingRefusesATail() + { + var registry = CultMeshCdn.CreateDocumentRegistry(); + var snapshot = new CultNetSnapshotResponseRawMessage { Documents = new[] { WithTail(Manifest()) } }; + Refusal(() => CultMesh.DecodeSnapshotDocuments(snapshot, registry)).Should().Contain(Clause); + } + + [Test] + public void ASnapshotDecodeWithNoBindingRefusesATail() + { + var snapshot = new CultNetSnapshotResponseRawMessage { Documents = new[] { WithTail(Manifest()) } }; + Refusal(() => CultMesh.DecodeSnapshotDocuments(snapshot, new CultNetDocumentRegistry())).Should().Contain(Clause); + } + + [Test] + public void ATypedReadOfOneSnapshotRecordRefusesATail() + { + var record = WithTail(Manifest()); + var snapshot = new CultNetSnapshotResponseRawMessage { MessageId = "m", Documents = new[] { record } }; + var read = typeof(CultMesh).GetMethod("ReadDocumentFromSnapshotResponse", BindingFlags.NonPublic | BindingFlags.Static)! + .MakeGenericMethod(typeof(CultMeshCdnArtifactManifest)); + Refusal(() => read.Invoke(null, new object[] { snapshot, record.SchemaId, record.RecordKey })).Should().Contain(Clause); + } +} diff --git a/tests/GameCult.Mesh.Tests/CultMeshContentVectorTests.cs b/tests/GameCult.Mesh.Tests/CultMeshContentVectorTests.cs index da764cc5..0f627cfb 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshContentVectorTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshContentVectorTests.cs @@ -462,12 +462,12 @@ public sealed class CultMeshContentVectorTests private static byte[] Reencode(CultMeshContentChunkResponseMessage message) => CultNetSchemaMessageSerialization.Serialize(message); - /// The one reader of manifest bytes: the document reader production stores and reads manifests with (CultPayloadDepth 256 included). + /// The one reader of manifest bytes: the production CultNet binding of the manifest (CultPayloadDepth 256 and the one-value rule included). private static CultMeshCdnArtifactManifest ReadManifest(byte[] bytes) => - (CultMeshCdnArtifactManifest)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(CultMeshCdnArtifactManifest), bytes); + (CultMeshCdnArtifactManifest)CultMeshCdn.CreateDocumentRegistry().GetByDocumentType(typeof(CultMeshCdnArtifactManifest))!.PayloadDeserializer(bytes); private static byte[] Reencode(CultMeshCdnArtifactManifest manifest) => - CultDocumentMessagePackSerialization.SerializeUntyped(manifest, typeof(CultMeshCdnArtifactManifest)); + CultMeshCdn.CreateDocumentRegistry().GetByDocumentType(typeof(CultMeshCdnArtifactManifest))!.PayloadSerializer(manifest); private static CultMeshContentChunkRequestMessage Request(string id, string hash, string key, int size) => new() { diff --git a/tests/GameCult.Mesh.Tests/CultMeshProviderSessionWireTests.cs b/tests/GameCult.Mesh.Tests/CultMeshProviderSessionWireTests.cs index 75125148..ca4346aa 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshProviderSessionWireTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshProviderSessionWireTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using GameCult.Networking; using MessagePack; using NUnit.Framework; @@ -168,6 +169,32 @@ public sealed class CultMeshProviderSessionWireTests }); } + [Test] + public void DecodeCommandDocumentRefusesBytesAfterItsFirstValue() + { + var command = new CultMeshProviderCommandWire + { + CommandId = "cmd-7", CommandKind = "pilot.thrust", ProviderId = "aetheria", ServiceInstanceId = "aetheria-42", + Payload = new Dictionary { ["thrust"] = 1 } + }; + var payload = Convert.FromBase64String(CultMeshProviderSessionWire.EncodePayload(command)); + CultNetRawDocumentRecord Record(byte[] bytes) => new() + { + SchemaId = CultMeshProviderSessionWireContract.CommandSchema, + RecordKey = "provider-command:aetheria:aetheria-42:cmd-7", + PayloadEncoding = "messagepack", + Payload = bytes + }; + + Assert.That(CultMeshProviderSessionWire.DecodeCommandDocument(Record(payload)).CommandId, Is.EqualTo("cmd-7")); + var thrown = Assert.Throws(() => + CultMeshProviderSessionWire.DecodeCommandDocument(Record(payload.Concat(new byte[] { 0xc0, 0xc0 }).ToArray())))!; + Assert.That(thrown.Message, Does.Contain("2 bytes after its first MessagePack value")); + var one = Assert.Throws(() => + CultMeshProviderSessionWire.DecodeCommandDocument(Record(payload.Concat(new byte[] { 0xc0 }).ToArray())))!; + Assert.That(one.Message, Does.Contain("1 bytes after its first MessagePack value")); + } + [Test] public void LeaseResponse_RoundTrips_WithApplicationAcceptance() { diff --git a/tests/GameCult.Networking.Tests/BytesAfterPathTests.cs b/tests/GameCult.Networking.Tests/BytesAfterPathTests.cs new file mode 100644 index 00000000..6a56ceb1 --- /dev/null +++ b/tests/GameCult.Networking.Tests/BytesAfterPathTests.cs @@ -0,0 +1,62 @@ +using System; +using System.Linq; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Networking.Tests +{ + // A record's payload is one MessagePack value: each CultNet path that decodes a payload refuses a tail, by the one rule in + // CultPayloadDepth.Check. One test per path family, so that pointing a path at a tolerant reader turns it red. + public class BytesAfterPathTests + { + private const string Clause = "1 bytes after its first MessagePack value"; + + [CultDocument("tests.bytes_after_net", "tests.bytes_after_net.v1")] + [MessagePackObject] + public sealed class TailDoc + { + [Key(0)] [CultName] public string Id = "a"; + [Key(1)] public int N = 3; + } + + private static readonly CultDocumentRegistry Documents = CultDocumentRegistry.ForTypes(new[] { typeof(TailDoc) }); + + private static byte[] WithTail() => + CultDocumentMessagePackSerialization.SerializeUntyped(new TailDoc(), typeof(TailDoc), Documents).Concat(new byte[] { 0xc0 }).ToArray(); + + private static CultNetRawDocumentRecord Raw(CultNetDocumentRegistry registry, byte[] payload) => new() + { + SchemaId = Documents.GetRequired(typeof(TailDoc)).SchemaId, + RecordKey = "a", + StoredAt = "1970-01-01T00:00:00.000Z", + Payload = payload, + }; + + private static void Refuses(TestDelegate read) => + Assert.That(Assert.Catch(read)!.ToString(), Does.Contain(Clause)); + + [Test] + public void TheGenericBindingRefusesATail() => + Refuses(() => CultNetDocumentBinding.ForDocument(Documents).PayloadDeserializer(WithTail())); + + [Test] + public void TheRuntimeTypeBindingRefusesATail() => + Refuses(() => CultNetDocumentBinding.ForDocument(typeof(TailDoc), Documents).PayloadDeserializer(WithTail())); + + [Test] + public void ARawEnvelopeThroughARegisteredBindingRefusesATail() + { + var registry = new CultNetDocumentRegistry(Documents).Register(CultNetDocumentBinding.ForDocument(Documents)); + Refuses(() => registry.DeserializeRawDocument(Raw(registry, WithTail()))); + } + + [Test] + public void ARawEnvelopeWithNoBindingRefusesATail() + { + var registry = new CultNetDocumentRegistry(Documents); + Refuses(() => registry.DeserializeRawDocument(Raw(registry, WithTail()))); + } + } +} diff --git a/tests/vectors/union-parity/README.md b/tests/vectors/union-parity/README.md index fbf9c6c1..525f5642 100644 --- a/tests/vectors/union-parity/README.md +++ b/tests/vectors/union-parity/README.md @@ -170,3 +170,22 @@ arrays around a canary string, which no formatter reads. | `depth-skipped-past-limit.msgpack` | 257 | refused, depth | refuse on read though the typed depth is under 10 | A walk of the payload must not recurse: 100000 nested arrays in an extra slot are refused on a 256 KiB stack. + +## Bytes after the value (bytes-after-outcomes.msgpack) + +A record's payload is exactly one MessagePack value. Each file is `union-surface.msgpack` followed by a tail the first value does not own; the C# reference writes them and every other runtime is held to the same refusal. + +| File | Tail after the value | outcome, detail | +|---|---|---| +| `bytes-after-nil.msgpack` | `c0` (1 byte) | refused, bytes-after | +| `bytes-after-never-used.msgpack` | `c1 c1` (2 bytes, the never-used code) | refused, bytes-after | +| `bytes-after-deep-tail.msgpack` | 300 nested fixarray(1) around a canary string (324 bytes), nested past the limit | refused, bytes-after | +| `bytes-after-second-document.msgpack` | a second copy of `union-surface.msgpack` | refused, bytes-after | + +`bytes-after-outcomes.msgpack` is an array of `[file, "refused", "UnionSurface", nil, "bytes-after"]` rows, the row shape of `bounds-outcomes.msgpack` but in its own file, so the bounds walkers (which assert 12 rows) are unchanged. + +Must: refuse on read, naming the type and the count of bytes after the first value (`1`, `2`, the tail length) in a clause that says "bytes after its first MessagePack value"; never echo the canary; never report the depth refusal ("the limit is") for any of them, because the tail is not walked; and a single-file store holding `bytes-after-nil.msgpack` as a record payload refuses on open. + +What a runtime walks: only the first value of the payload, to find where it ends (a leaf counts as complete at its own length, a container when its last child completes). It compares that end with the payload length; a payload that ends early is not bytes after the value and stays the decoder's to refuse. It never walks the tail, so the tail's own shape (a stray `c1`, 300 levels) is never judged. + +A CDN manifest read as a record follows the same rule; its vectors are `contracts/cultmesh/content-acceptance-vectors.json` (`trailing_*` are refusals). diff --git a/tests/vectors/union-parity/bytes-after-deep-tail.msgpack b/tests/vectors/union-parity/bytes-after-deep-tail.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..2dcc82f9cf5235956c37ed6c9e429bfca556b629 GIT binary patch literal 601 zcmbQfytuR|EipNjc@o2%6!aU5${fLW866AE~O}j2j2oI3a+MgjvPa?*y267{?Fp%UW~* literal 0 HcmV?d00001 diff --git a/tests/vectors/union-parity/bytes-after-nil.msgpack b/tests/vectors/union-parity/bytes-after-nil.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..3bbdaa3321d1d4def22b1d20c9bb3a044b211d1c GIT binary patch literal 278 zcmZ9GK@P$o5Jk0W+<2D)-Fcf4&^Cc!D2*ON2NSm%(QZ5hN0Wsy;H+kT{(pb2V?Wp} za5=*XzR7Pm@i;96`fR0A1}mT27zS?iz6=Y3WmwSr5f_&%DA+jIZe7<9AE4Y{nWUjt w#>EmV*{Ou^<_p=xn@`?wfX-zlK+_w`Twqw8^SeogR|{H8yGm==NwD)6Ka^HkvH$=8 literal 0 HcmV?d00001 diff --git a/tests/vectors/union-parity/bytes-after-outcomes.msgpack b/tests/vectors/union-parity/bytes-after-outcomes.msgpack new file mode 100644 index 00000000..8237924e --- /dev/null +++ b/tests/vectors/union-parity/bytes-after-outcomes.msgpack @@ -0,0 +1 @@ +”•·bytes-after-nil.msgpack§refused¬UnionSurfaceÀ«bytes-after•¾bytes-after-never-used.msgpack§refused¬UnionSurfaceÀ«bytes-after•½bytes-after-deep-tail.msgpack§refused¬UnionSurfaceÀ«bytes-after•Ù#bytes-after-second-document.msgpack§refused¬UnionSurfaceÀ«bytes-after \ No newline at end of file diff --git a/tests/vectors/union-parity/bytes-after-second-document.msgpack b/tests/vectors/union-parity/bytes-after-second-document.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..80d136fa80a0680a2d93d8a5b7a42a3adbc660e1 GIT binary patch literal 554 zcmbQfytuR|EipNjc@o2GE Dx2>_` literal 0 HcmV?d00001 diff --git a/unity/org.gamecult.cultlib/CHANGELOG.md b/unity/org.gamecult.cultlib/CHANGELOG.md index a2a1efae..a4a2bc11 100644 --- a/unity/org.gamecult.cultlib/CHANGELOG.md +++ b/unity/org.gamecult.cultlib/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this package are documented in this file. ### Breaking +- A document payload with bytes after its first MessagePack value is refused on read and on write (`MessagePackSerializationException` naming the type and the count of bytes after the value, never the bytes); the tail is not walked, so a tail nested past the limit is refused as bytes after the value, not as depth. Before, the tail was ignored on read and dropped on the next write. A payload that ends early is still the deserializer's to refuse. A CDN manifest is a record payload and follows the same rule: the acceptance vectors `trailing_nil`, `trailing_reserved_code` and `trailing_truncated_array` in `contracts/cultmesh/content-acceptance-vectors.json` are now refusals. `CultMeshProviderSessionWire.DecodeCommandDocument` refuses a command record whose payload has bytes after its first value (`MessagePackSerializationException` with the count); before, the tail was ignored. Semver: breaking for C# consumers, a major bump. - A single-file store holding two records under one key is unreadable: the reader refuses it with `CultStoreUnreadableException` naming the key (keys compare by ordinal code units, no case folding or normalisation), so a cache, `ReadStoreSnapshot` and a flush refuse the file and leave it as it is. Before, the later record silently replaced the earlier. A directory store reads its manifest through the same reader and refuses a duplicate key the same way. Repair the duplicate with the runtime that wrote it before reading the store here. - A record whose payload carries a slot past the slots its reading type declares, at any depth (the record, a nested object, a list element, a union arm or a unit arm), is loaded read-only: it is served, and every staged write or removal of its key (`Upsert`, `UpsertVariant`, `Remove`, a batch, and `Flatten` of a variant) is refused with `CultSchemaConflictException` before anything is staged, and the store keeps the record byte for byte. Before, the extra slots were skipped on read and dropped on the next write. Declaring an id compatible consents only to depth-0 slots the schema comparison reports ignored under that id; any other extra slot keeps the record read-only whatever is declared, including under the reader's own id. A variant overriding a slot its type does not have is read-only too (it used to be shed on flush). Write such a record from the build that declares those slots. Missing trailing slots still default and stay writable. - `Flatten` (and `FlattenAsync`) of a variant whose base chain holds a record its store does not own (a record held read-only for extra slots, or undeclared drift that drops slots) is refused with `CultSchemaConflictException` naming that base record, and the store is left as it was. Before, the flatten wrote the base's decoded view into a plain record and the base's unread data was lost for that variant. Writing and removing the variant itself are still accepted: they persist only its delta. From 892765ba97824b8de8f16b7a74ec32a2020eeda8 Mon Sep 17 00:00:00 2001 From: Libby Date: Thu, 12 Nov 2026 04:24:07 +0000 Subject: [PATCH 46/93] Python: a record with unread slots is held read-only (slot-readonly-py) (#126) --- packages/cultcache-py/CHANGELOG.md | 2 + packages/cultcache-py/README.md | 11 + .../cultcache-py/src/cultcache_py/__init__.py | 2 + .../cultcache-py/src/cultcache_py/cache.py | 113 +++- .../src/cultcache_py/documents.py | 90 +++- .../cultcache-py/src/cultcache_py/stores.py | 16 +- .../cultcache-py/tests/test_slot_readonly.py | 492 ++++++++++++++++++ .../cultcache-py/tests/test_union_parity.py | 8 +- 8 files changed, 704 insertions(+), 30 deletions(-) create mode 100644 packages/cultcache-py/tests/test_slot_readonly.py diff --git a/packages/cultcache-py/CHANGELOG.md b/packages/cultcache-py/CHANGELOG.md index 95f82042..08fb4d1f 100644 --- a/packages/cultcache-py/CHANGELOG.md +++ b/packages/cultcache-py/CHANGELOG.md @@ -9,6 +9,8 @@ ### Breaking +- A record whose payload carries a slot past its reader's declared slots, at any depth (the document, a nested record, a list element, a union arm, a unit arm), is read, held read-only, and every write or removal of its key (`put`, `put_global`, `update`, `put_envelope`, `put_envelopes`, `delete`, `delete_global`) is refused with `SchemaConflictError` before any store is touched (`cultlib-gaps:ruling:extra-trailing-slots`). Reading dropped those slots silently and the next write shed them. `SchemaConflictError.unread_slots` is an `UnreadSlots(type_name, slot_count, declared_count, occurrences, depth0_only)` and the message names the type and both counts, never a slot value. A depth-0 mark is consented, and the record writable, only when the record is stored under an id the document lists in `compatible_schema_ids` and that id's catalog entry has slots past the document's, covering the mark's slot count (`cultlib-gaps:ruling:slot-mark-consent`); a nested mark, or a mark under the document's own id, keeps the record read-only whatever is declared. Missing trailing slots keep their declared defaults. An untouched marked record keeps its stored bytes through every other write. Added `CultCache.is_writable(document, key)` (advisory; the write still decides: it also answers False for a foreign-carried key, for a key held as a type the document does not own, and for a global document at a non-global key; `delete` asks the same judges, so removing a key held as another type is refused with `SchemaConflictError` unless the document owns that record's schema id (its own or one it lists compatible), in which case the removal removes that record, its file row and its lookups, as a write of the key would replace it; it returned silently and removed nothing), `DocumentDefinition.decode_payload_marked`, and `UnreadSlots`. A document with a custom `decode` (no slot codec) never marks. A store holding a variant record is still unreadable here. Implies a minor bump while the version is 0.x (0.3.0 to 0.4.0): code that wrote back a record from a newer writer now gets `SchemaConflictError`. + - A cache holds one record per key whatever its type (`variants:ruling:cache-refuses-retype`). A `put`, `put_envelope`, `put_envelopes` or `put_global` of a second type under a key the cache holds, or under a key a store carries foreign, is refused with `SchemaConflictError` naming the key, even with no store, and changes nothing; retype a key by deleting it, then putting the new type. A write may replace another registered type's record only when the written type owns its schema id (declares it or lists it compatible) and both types share one home store. A load that reads one key from two stores, whatever the types, is refused with `CultCacheError` naming the key and both types, and admits nothing. A store that holds a record no registered type claims now loads and is carried: the cache does not serve it, the store keeps its bytes through every write, `delete` or `put` at its key is refused with `SchemaConflictError`, and registering a type that declares its schema id claims it at the next load. It raised `CultCacheError` "Unknown persisted document type". Two global types share the key `__global__`, so a second global type in one cache is now refused, and a store that already holds two global types at `__global__` (a JSONL store written by caches that never pulled could produce one) no longer loads: the pull raises `CultCacheError` and admits nothing. C# refuses a second persisted global on pull the same way (`cultcache-store-composition.md`, "Globals never invented"); repair such a file by deleting one of the two records with the runtime that wrote it. - Added `foreign_records()` and the frozen `ForeignRecord(key, schema_id, schema_name, stored_at)`, sorted by key in UTF-16 code-unit order. `stores.retyped` and `stores.written_owns` lose their underscore: the cache judge and the single-file store's commit guard ask one predicate. diff --git a/packages/cultcache-py/README.md b/packages/cultcache-py/README.md index 43e6c90e..72d51562 100644 --- a/packages/cultcache-py/README.md +++ b/packages/cultcache-py/README.md @@ -97,6 +97,7 @@ store. - `update_global(...)` - `delete(...)` - `delete_global(...)` +- `is_writable(document, key)` (advisory: would a write or removal of the key be refused; see below) - `snapshot()` ## Indexes And Roles @@ -180,6 +181,16 @@ The payload is a MessagePack array. Field keys are durable slot indexes: - newly added fields should use new keys - fields with defaults tolerate missing or nil slots when older payloads are read +A payload carrying a slot past the slots a reader declares, at any depth (the document, a nested record, a list element, a +union arm, a unit arm), was written by a newer build. It is read, and the record is held read-only: `put`, `update`, +`put_envelope(s)`, `delete` and their global forms refuse its key with `SchemaConflictError` before any store is touched, and +`SchemaConflictError.unread_slots` is an `UnreadSlots(type_name, slot_count, declared_count, occurrences, depth0_only)` naming the +first type read with extra slots (never a slot value). `CultCache.is_writable(document, key)` answers the same question before +the write: False for such a record, for a key a store carries foreign, and for a key held as another type's record; the write +still decides. `DocumentDefinition.decode_payload_marked(payload)` returns the value and its `UnreadSlots` (or None). Declaring the +stored id in `compatible_schema_ids` consents to the loss of depth-0 slots that id's catalog entry reports; a nested mark never is. +A store keeps a read-only record's bytes through every other write. A document with a custom `decode` never marks. + That matches the Rust `#[derive(DatabaseEntry)]` formatter shape and the C# `[Key(n)]` intent: schema evolution comes from stable field slots, not source member order. diff --git a/packages/cultcache-py/src/cultcache_py/__init__.py b/packages/cultcache-py/src/cultcache_py/__init__.py index dc73ab1b..4873b6ed 100644 --- a/packages/cultcache-py/src/cultcache_py/__init__.py +++ b/packages/cultcache-py/src/cultcache_py/__init__.py @@ -9,6 +9,7 @@ from .documents import ( DatabaseEntryField, DocumentDefinition, UnionArm, + UnreadSlots, database_entry_field, define_database_entry_type, define_document_registry, @@ -38,6 +39,7 @@ __all__ = [ "SlotCodec", "StoreUnreadableError", "UnionArm", + "UnreadSlots", "leaves", "database_entry_field", "define_database_entry_type", diff --git a/packages/cultcache-py/src/cultcache_py/cache.py b/packages/cultcache-py/src/cultcache_py/cache.py index 315e64ac..85b01d89 100644 --- a/packages/cultcache-py/src/cultcache_py/cache.py +++ b/packages/cultcache-py/src/cultcache_py/cache.py @@ -8,7 +8,7 @@ from dataclasses import dataclass, field, replace from typing import Any, Callable, Generic, Iterable, TypeVar from .backing_store import BackingStore, CultCacheEnvelope -from .documents import DocumentDefinition, extract_value +from .documents import DocumentDefinition, UnreadSlots, extract_value from .stores import SchemaConflictError, retyped, written_owns T = TypeVar("T") @@ -123,6 +123,10 @@ class _State: # The records the last pull read that no registered type claims, sorted by key. Derived from the pull and swapped with the # rest of the state, written nowhere else; it decides only refusals at its keys. A store keeps their bytes. foreign: list["ForeignRecord"] = field(default_factory=list) + # The records held read-only, by key (a key holds one record) with the type that holds it: the payload carried slots past the + # declared ones and nothing consents to the loss. Derived from the load or the write that admitted the record and swapped with + # the rest of the state; it decides only refusals at its keys, and any other key is absent. + unread: dict[str, tuple[str, UnreadSlots]] = field(default_factory=dict) @dataclass(frozen=True) @@ -337,6 +341,7 @@ class CultCache: routes, generic = self._state.stores_by_type, self._state.generic_store seen_globals: set[str] = set() foreign: list[ForeignRecord] = [] + loaded_unread: dict[str, tuple[str, UnreadSlots]] = {} type_at_key: dict[str, tuple[str, int]] = {} # one record per key across every store: its type (or schema name) and store stores: list[BackingStore] = [] for candidate in [*self._state.stores_by_type.values(), self._state.generic_store]: @@ -376,8 +381,10 @@ class CultCache: loaded_legacy_global_keys[envelope.type] = envelope.key envelope = replace(envelope, key=GLOBAL_KEY) self._claim_key(type_at_key, envelope.key, envelope.type, position) - envelope = self._stamped(document, envelope) - value = document.decode_payload(envelope.payload) + arrived, envelope = envelope, self._stamped(document, envelope) + value, unread = self._decode_marked(document, arrived) + if unread is not None: + loaded_unread[envelope.key] = (envelope.type, unread) loaded_values.setdefault(envelope.type, {})[envelope.key] = value read_values[(envelope.type, envelope.key)] = value loaded_envelopes.setdefault(envelope.type, {})[envelope.key] = envelope @@ -393,6 +400,7 @@ class CultCache: self._state.lookup_keys, self._state.legacy_global_keys, self._state.foreign, + self._state.unread, ) = ( loaded_values, loaded_envelopes, @@ -401,6 +409,7 @@ class CultCache: loaded_lookup_keys, loaded_legacy_global_keys, sorted(foreign, key=lambda record: _utf16_order(record.key)), + loaded_unread, ) @staticmethod @@ -491,9 +500,9 @@ class CultCache: def put_envelope(self, document: DocumentDefinition[T], envelope: CultCacheEnvelope) -> T: self._assert_registered(document) self._check_envelope(document, envelope) - envelope = self._stamped(document, envelope) - value = document.decode_payload(envelope.payload) - self._write(document, [(envelope, value)], batch=False) + arrived, envelope = envelope, self._stamped(document, envelope) + value, unread = self._decode_marked(document, envelope) + self._write(document, [(envelope, value)], batch=False, unread={envelope.key: unread}) return value @_mutating @@ -501,13 +510,33 @@ class CultCache: self._assert_registered(document) values: list[T] = [] stamped: list[CultCacheEnvelope] = [] + unread: dict[str, UnreadSlots | None] = {} for envelope in envelopes: self._check_envelope(document, envelope) stamped.append(self._stamped(document, envelope)) - values.append(document.decode_payload(envelope.payload)) - self._write(document, list(zip(stamped, values)), batch=True) + value, unread[envelope.key] = self._decode_marked(document, stamped[-1]) + values.append(value) + self._write(document, list(zip(stamped, values)), batch=True, unread=unread) return values + @staticmethod + def _decode_marked(document: DocumentDefinition[Any], held_as: CultCacheEnvelope) -> tuple[Any, UnreadSlots | None]: + """The payload decoded under the document, with the mark of any slot it carried past the declared ones, or no mark when the + loss is consented. `held_as` is the envelope under the id the record is stored under: a load's arrival, a write's stamped + envelope, so a key is writable in memory exactly when it is after a reload. A depth-0 mark is consented only when that id is + one the document lists compatible and its catalog entry has slots past the document's that cover the mark's slot count (the + loss a compatible-id declaration consents to, cultlib-gaps:ruling:slot-mark-consent). Any other mark keeps the record read-only.""" + value, slots = document.decode_payload_marked(held_as.payload) + if slots is None: + return value, None + entry, stored = document.catalog_entry(), held_as.catalog_entry + if slots.depth0_only and stored is not None and held_as.schema_id in entry.compatible_schema_ids and held_as.schema_id != entry.schema_id: + declared = {member.slot for member in entry.members} + reported = [member.slot for member in stored.members if member.slot not in declared] + if reported and slots.slot_count <= max(reported) + 1: + return value, None + return value, slots + @staticmethod def _stamped(document: DocumentDefinition[Any], envelope: CultCacheEnvelope) -> CultCacheEnvelope: """The record under its registered schema's id, whatever id the envelope arrived under, so the catalog entry a store @@ -527,7 +556,12 @@ class CultCache: raise CultCacheError(f"Global document {document.type} must use key {GLOBAL_KEY}") def _write( - self, document: DocumentDefinition[Any], records: list[tuple[CultCacheEnvelope, Any]], *, batch: bool + self, + document: DocumentDefinition[Any], + records: list[tuple[CultCacheEnvelope, Any]], + *, + batch: bool, + unread: dict[str, UnreadSlots | None] | None = None, ) -> None: """Validates every record (home and name and index extractors included) before the store is touched; once the store accepts, applying to the cache has no fallible step.""" @@ -535,6 +569,7 @@ class CultCache: # A batch naming a key twice lands the last record for it, as C# CultCacheBatch stages one operation per key. The # judge, the store and the apply all read this one list. records = list({envelope.key: (envelope, value) for envelope, value in records}.values()) + self._refuse_unread([envelope.key for envelope, _ in records]) name_extractor = self._state.name_extractors.get(type) index_extractors = self._state.index_extractors.get(type, {}) admitted = [ @@ -566,6 +601,9 @@ class CultCache: self._remove_lookups(type, envelope.key) values[envelope.key] = value envelopes[envelope.key] = envelope + # A held mark refused this write above, so a key reaching here carries none to clear. + if (mark := (unread or {}).get(envelope.key)) is not None: + self._state.unread[envelope.key] = (type, mark) lookup_keys[envelope.key] = keys if keys.name is not None: self._state.names[(type, keys.name)] = envelope.key @@ -592,6 +630,51 @@ class CultCache: replaced.append((other, envelope.key)) return replaced + @_locked + def is_writable(self, document: DocumentDefinition[Any], key: str) -> bool: + """Whether a write or removal of the key by this document would pass the judges that run before any store is touched: False for + a record held read-only (its payload carried slots past the declared ones, at any depth, and nothing consents to the loss, so + writing it would drop data a newer writer stored), for a key a store carries foreign, and for a key held as another type's + record this document does not own. Advisory (the write still decides): it answers at call time, and a key nothing holds is + writable.""" + self._assert_registered(document) + try: + self._refuse_at(document, key) + except (SchemaConflictError, CultCacheError): + return False + return True + + def _refuse_at(self, document: DocumentDefinition[Any], key: str) -> list[tuple[str, str]]: + """The judges a write or removal of the key by this document passes before any store is touched, asked by `is_writable` and + by `delete`: a global document writes only its global key, a record held read-only is never replaced, and a key held as + another type's record or carried foreign is refused (`_refuse_retype`).""" + if document.global_document and key != GLOBAL_KEY: + raise CultCacheError(f"Global document {document.type} must use key {GLOBAL_KEY}") + self._refuse_unread([key]) + entry = document.catalog_entry() + probe = CultCacheEnvelope.create(key=key, type=document.type, payload=b"", schema_id=entry.schema_id, catalog_entry=entry) + return self._refuse_retype(document, [probe]) + + def _refuse_unread(self, keys: list[str]) -> None: + """The one check every write and removal asks before any store is touched.""" + for key in keys: + held = self._state.unread.get(key) + if held is None: + continue + type, slots = held + raise SchemaConflictError( + self._state.envelopes[type][key].schema_id or type, + [self._state.documents[type].catalog_entry().schema_name], + key, + message=( + f"{self._state.documents[type].catalog_entry().schema_name} record {key} is read-only: {slots.type_name} was read with " + f"{slots.slot_count} slots where this build declares {slots.declared_count} ({slots.occurrences} object(s) in the " + "record). Writing it would drop data a newer writer stored; read it with types that declare those slots, or " + "write it from that build." + ), + unread_slots=slots, + ) + @staticmethod def _foreign_refusal(record: ForeignRecord) -> SchemaConflictError: return SchemaConflictError( @@ -644,16 +727,18 @@ class CultCache: def _delete(self, document: DocumentDefinition[Any], key: str) -> None: self._assert_registered(document) - if key not in self._state.envelopes.get(document.type, {}): - carried = next((record for record in self._state.foreign if record.key == key), None) - if carried is not None: - raise self._foreign_refusal(carried) + # A key held as another type's record this document owns is removed, as a write of the key would replace it (C# Remove is by key). + owned = self._refuse_at(document, key) store = self._store_for_type(document.type) legacy_key = self._state.legacy_global_keys.get(document.type) if key == GLOBAL_KEY else None if store is not None: - store.delete(document.type, key if legacy_key is None else legacy_key) + # One record per key: the owned record when this document does not hold the key, else its own, in one store call. + holder = owned[0][0] if owned else document.type + store.delete(holder, key if legacy_key is None else legacy_key) if legacy_key is not None: del self._state.legacy_global_keys[document.type] + for other, _ in owned: + self._drop(other, key) self._drop(document.type, key) def _drop(self, type: str, key: str) -> None: diff --git a/packages/cultcache-py/src/cultcache_py/documents.py b/packages/cultcache-py/src/cultcache_py/documents.py index 6101c136..4f7dfe7d 100644 --- a/packages/cultcache-py/src/cultcache_py/documents.py +++ b/packages/cultcache-py/src/cultcache_py/documents.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import threading from dataclasses import MISSING, dataclass, field from collections.abc import Mapping, Sequence, Set from itertools import chain @@ -30,6 +31,49 @@ MAX_SLOT_KEY = 65535 _STACK_FAULTS: tuple[type[BaseException], ...] = (_MsgpackStackError,) +@dataclass(frozen=True) +class UnreadSlots: + """What a payload decode found past the slots this runtime declares, at any depth: the first type read with extra slots, how + many slots it carried against how many it declares, and how many objects in the payload did. It holds no slot value, so a + refusal can name it. `depth0_only` is True when every mark sat in the document's own record (the half of consent the payload + decides). A record carrying it is read-only unless the schema comparison explains every mark (see `CultCache.is_writable`).""" + + type_name: str + slot_count: int + declared_count: int + occurrences: int + depth0_only: bool + + +class _SlotScope: + """Opened by `DocumentDefinition.decode_payload_marked`; the record codec and the union codec note into it. It is thread-local, + so no codec signature carries it, and a codec decoded outside a payload decode notes nothing.""" + + def __init__(self) -> None: + self.depth = 0 # records entered: 0 while the document's own record is read + self.first: tuple[str, int, int] | None = None + self.occurrences = 0 + self.depth0_only = True + + def note(self, type_name: str, slot_count: int, declared_count: int, depth0: bool) -> None: + if self.first is None: + self.first = (type_name, slot_count, declared_count) + self.occurrences += 1 + self.depth0_only = self.depth0_only and depth0 + + def result(self) -> UnreadSlots | None: + if self.first is None: + return None + return UnreadSlots(*self.first, self.occurrences, self.depth0_only) + + +_scope = threading.local() + + +def _current_scope() -> _SlotScope | None: + return getattr(_scope, "open", None) + + _CONTAINERS = (Mapping, Sequence, Set) _SCALAR_SEQUENCES = (str, bytes, bytearray, memoryview) @@ -185,15 +229,25 @@ class DocumentDefinition(Generic[T]): return self.payload_encoder(tree) def decode_payload(self, payload: bytes) -> T: + return self.decode_payload_marked(payload)[0] + + def decode_payload_marked(self, payload: bytes) -> tuple[T, UnreadSlots | None]: + """The value and what the decode found past the declared slots, at any depth. The payload depth check runs first: a + payload past the limit is refused, never marked.""" try: tree = self.payload_decoder(payload) except _STACK_FAULTS: raise CultCachePayloadDepthError(self.type, "read") from None _refuse_past_depth_limit(tree, self.type, "read") - value = self.decode(tree) + outer, opened = _current_scope(), _SlotScope() + _scope.open = opened + try: + value = self.decode(tree) + finally: + _scope.open = outer if self.validate: self.validate(value) - return value + return value, opened.result() def __post_init__(self) -> None: # Judged here so every constructor passes it: the factories, the dataclass constructor and dataclasses.replace. @@ -418,6 +472,9 @@ def define_union( raise ValueError(f"Union {name!r} value is malformed: expected [armKey, [armSlots]]") arm_name, codec = by_key[key] if codec is None: + scope = _current_scope() + if scope is not None and raw[1]: + scope.note(arm_name, len(raw[1]), 0, False) return UnionArm(arm_name, None) try: return UnionArm(arm_name, codec.decode(raw[1])) @@ -479,17 +536,26 @@ def _record_codec( def decode(raw: Any) -> Any: if not isinstance(raw, list): raise ValueError(f"DatabaseEntry document type {type!r} expected a MessagePack array") + scope = _current_scope() + if scope is not None and len(raw) > max_key + 1: + scope.note(type, len(raw), max_key + 1, scope.depth == 0) values: dict[str, Any] = {} - for field in normalized: - if field.key < len(raw): - try: - values[field.name] = field.codec.decode(raw[field.key]) - except ValueError as error: - raise ValueError(f"{type} slot {field.key} ({field.name}): {error}") from None - elif field.default is MISSING: - values[field.name] = None - else: - values[field.name] = field.default() if callable(field.default) else field.default + if scope is not None: + scope.depth += 1 + try: + for field in normalized: + if field.key < len(raw): + try: + values[field.name] = field.codec.decode(raw[field.key]) + except ValueError as error: + raise ValueError(f"{type} slot {field.key} ({field.name}): {error}") from None + elif field.default is MISSING: + values[field.name] = None + else: + values[field.name] = field.default() if callable(field.default) else field.default + finally: + if scope is not None: + scope.depth -= 1 if cls is None: return values return cls(**values) diff --git a/packages/cultcache-py/src/cultcache_py/stores.py b/packages/cultcache-py/src/cultcache_py/stores.py index 61841956..c15ac32e 100644 --- a/packages/cultcache-py/src/cultcache_py/stores.py +++ b/packages/cultcache-py/src/cultcache_py/stores.py @@ -13,6 +13,7 @@ from typing import Any, Callable, Iterator, TypeVar import msgpack +from .documents import UnreadSlots from .backing_store import ( CultCacheEnvelope, CultCacheSchemaCatalogEntry, @@ -46,9 +47,19 @@ class SchemaConflictError(ValueError): """A write the catalog cannot describe: records of different types under one schema id, two arrived schemas that share an id and disagree on the schema name, or a record no chosen entry publishes. Nothing is written. It names the id, the names involved (`schema_names`) and a record key (`record_key`). A cache raises it too, with an empty record key, when a - document registers a schema id another registered document already carries.""" + document registers a schema id another registered document already carries. `unread_slots` is None except for the refusal to + write or remove a record held read-only because its payload carries slots past the declared ones; its message names the type + and both slot counts and never holds a slot value.""" - def __init__(self, schema_id: str, schema_names: list[str], record_key: str, *, message: str | None = None) -> None: + def __init__( + self, + schema_id: str, + schema_names: list[str], + record_key: str, + *, + message: str | None = None, + unread_slots: UnreadSlots | None = None, + ) -> None: super().__init__( message or f"Schema id {schema_id!r} cannot describe record {record_key!r}: {', '.join(repr(name) for name in schema_names)}" @@ -56,6 +67,7 @@ class SchemaConflictError(ValueError): self.schema_id = schema_id self.schema_names = schema_names self.record_key = record_key + self.unread_slots = unread_slots class JsonLinesBackingStore: diff --git a/packages/cultcache-py/tests/test_slot_readonly.py b/packages/cultcache-py/tests/test_slot_readonly.py new file mode 100644 index 00000000..77d4a989 --- /dev/null +++ b/packages/cultcache-py/tests/test_slot_readonly.py @@ -0,0 +1,492 @@ +from __future__ import annotations + +import dataclasses +import shutil +import threading +import tempfile +import unittest +from pathlib import Path + +import msgpack +from cultcache_py.cache import CultCacheError + +from cultcache_py import ( + CultCache, + CultCachePayloadDepthError, + SlotCodec, + SchemaConflictError, + SingleFileMessagePackBackingStore, + UnionArm, + UnreadSlots, + database_entry_field, + define_database_entry_type, + define_record_codec, + leaves as t, +) +import test_union_parity as _parity +from test_union_parity import ( + VECTORS, + _surface_value, + _vector, + surface_document, + unit_outcome, +) + +SLOT = "extra-slot" # the canary: the value of every extra slot in the vectors; no refusal may carry it + +# The vectors name the C# types; this runtime names a type by its codec's name, a document by its document type. +_NAMES = {"UnionSurface": surface_document.type, "SurfaceDeclaring": surface_document.type, "Denied2": "Denied"} + +slot_unit_document = define_database_entry_type( + "tests.slot_unit", + [database_entry_field("name", 0, codec=t.string), database_entry_field("outcome", 1, codec=unit_outcome)], + name="name", +) +# The reader of extra-slot-consent.cc: the surface document's slots under a new schema id that lists the old one compatible. +declaring_document = define_database_entry_type( + "tests.union_surface", + [ + database_entry_field("id", 0, codec=t.string), + database_entry_field("version", 1, codec=t.int32), + database_entry_field("root", 2, codec=_parity._node), + database_entry_field("outcomes", 3, codec=t.list(_parity.outcome)), + database_entry_field("part", 4, codec=t.nullable(_parity.item)), + database_entry_field("crafted", 5, codec=t.nullable(_parity.crafted)), + database_entry_field("missing", 6, codec=_parity._node), + ], + name="id", + schema_id="tests.union_surface.v2", + schema_name="tests.union_surface", + compatible_schema_ids=("tests.union_surface.wide",), +) + +# file -> (type, slots carried, slots declared, every mark at depth 0): the README table. +_PAYLOADS = { + "extra-slot-record.msgpack": ("UnionSurface", 8, 7, True), + "extra-slot-nested-record.msgpack": ("Composition", 3, 2, False), + "extra-slot-arm.msgpack": ("Box", 3, 2, False), + "extra-slot-list-element.msgpack": ("Text", 3, 2, False), + "depth-skipped-at-limit.msgpack": ("UnionSurface", 8, 7, True), +} + + +def _copy_store_without_variants(source: Path, target: Path) -> None: + """This runtime holds no variant records (a five-slot record makes a store unreadable here), so the store vector is + walked with its variant row removed; every other byte is the C# writer's.""" + [snapshot] = list(msgpack.Unpacker(open(source, "rb"), raw=False)) + snapshot[2] = [record for record in snapshot[2] if len(record) <= 4] + target.write_bytes(msgpack.packb(snapshot, use_bin_type=True)) + + +def _open(path: Path, *documents) -> CultCache: + builder = CultCache.builder() + for document in documents: + builder.register_document_type(document) + cache = builder.add_generic_store(SingleFileMessagePackBackingStore(path)).build() + cache.pull_all_backing_stores() + return cache + + +def _rows(): + return msgpack.unpackb(_vector("slot-outcomes.msgpack"), raw=False) + + +class PayloadMarkTests(unittest.TestCase): + def test_every_read_only_payload_decodes_and_names_its_mark(self) -> None: + for file, (type_name, carried, declared, depth0) in _PAYLOADS.items(): + with self.subTest(file=file): + value, mark = surface_document.decode_payload_marked(_vector(file)) + self.assertEqual(value, _surface_value()) + self.assertEqual(mark, UnreadSlots(_NAMES.get(type_name, type_name), carried, declared, 1, depth0)) + self.assertEqual(surface_document.decode_payload(_vector(file)), _surface_value()) + + def test_the_outcomes_manifest_still_counts_nine_rows_and_four_read_only(self) -> None: + rows = msgpack.unpackb(_vector("outcomes.msgpack"), raw=False) + self.assertEqual(len(rows), 9) + self.assertEqual([row[0] for row in rows if row[1] == "read-only"], [f for f in _PAYLOADS if f != "depth-skipped-at-limit.msgpack"]) + + def test_a_clean_payload_and_a_short_one_carry_no_mark(self) -> None: + self.assertEqual(surface_document.decode_payload_marked(_vector("union-surface.msgpack")), (_surface_value(), None)) + slots = msgpack.unpackb(_vector("union-surface.msgpack"), raw=False) + slots[2] = [1, ["only-id"]] # a Text arm with a missing trailing slot: defaults, no mark + _, mark = surface_document.decode_payload_marked(msgpack.packb(slots, use_bin_type=True)) + self.assertIsNone(mark) + + def test_a_retired_slot_below_the_highest_key_does_not_mark(self) -> None: + slots = msgpack.unpackb(_vector("union-surface.msgpack"), raw=False) + # Image's declared slots are 0, 1, 3 (2 retired): four carried, four declared. + image = slots[2][1][1][2] + self.assertEqual(image[0], 5) + image[1][2] = "retired-slot-value" + _, mark = surface_document.decode_payload_marked(msgpack.packb(slots, use_bin_type=True)) + self.assertIsNone(mark) + + def test_the_depth_check_runs_before_the_mark(self) -> None: + with self.assertRaises(CultCachePayloadDepthError): + surface_document.decode_payload_marked(_vector("depth-skipped-past-limit.msgpack")) + + def test_the_unit_arm_with_extra_slots_is_read_past_and_marked(self) -> None: + payload = msgpack.packb(["unit-arm", [2, [SLOT]]], use_bin_type=True) + value, mark = slot_unit_document.decode_payload_marked(payload) + self.assertEqual(value, {"name": "unit-arm", "outcome": UnionArm("Denied")}) + self.assertEqual(mark, UnreadSlots("Denied", 1, 0, 1, False)) + self.assertEqual(slot_unit_document.decode_payload_marked(msgpack.packb(["ok", [2, []]]))[1], None) + + def test_a_codec_decoded_outside_a_payload_decode_notes_nothing(self) -> None: + self.assertEqual(unit_outcome.decode([2, [SLOT]]), UnionArm("Denied")) + + def test_the_scope_belongs_to_its_thread(self) -> None: + def decode_elsewhere(raw): + thread = threading.Thread(target=lambda: unit_outcome.decode([2, [SLOT]])) + thread.start() + thread.join() + return raw + + outer = define_database_entry_type("tests.threaded", [database_entry_field("a", 0, codec=SlotCodec(lambda v: v, decode_elsewhere))]) + self.assertIsNone(outer.decode_payload_marked(msgpack.packb(["x"]))[1]) + + def test_a_nested_payload_decode_restores_the_outer_scope(self) -> None: + def decode_clean_inside(raw): + surface_document.decode_payload_marked(_vector("union-surface.msgpack")) + return raw + + inner = define_record_codec("Inner", [database_entry_field("x", 0, codec=t.string)]) + outer = define_database_entry_type( + "tests.nested", + [ + database_entry_field("a", 0, codec=SlotCodec(lambda v: v, decode_clean_inside)), + database_entry_field("b", 1, codec=inner), + ], + ) + _, mark = outer.decode_payload_marked(msgpack.packb(["x", ["v", SLOT]])) + self.assertEqual(mark, UnreadSlots("Inner", 2, 1, 1, False)) + + +class StoreRowTests(unittest.TestCase): + def setUp(self) -> None: + self.tmp = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, self.tmp, True) + + def _store(self, name: str) -> tuple[Path, CultCache]: + path = self.tmp / name + _copy_store_without_variants(VECTORS / name, path) + documents = (declaring_document,) if name == "extra-slot-consent.cc" else (surface_document, slot_unit_document) + return path, _open(path, *documents) + + def _document(self, store: str, key: str): + if key == "unit-arm": + return slot_unit_document + return declaring_document if store == "extra-slot-consent.cc" else surface_document + + def test_every_row_is_writable_exactly_where_the_vector_says_and_refuses_with_its_mark(self) -> None: + walked = 0 + for store, key, writable, explained, type_name, carried, declared, occurrences, flatten in _rows(): + if flatten is not None: + continue # a variant: this runtime holds none (deviation) + walked += 1 + with self.subTest(store=store, key=key): + path, cache = self._store(store) + document = self._document(store, key) + before = path.read_bytes() + self.assertEqual(cache.is_writable(document, key), writable) + held = cache.get(document, key) + self.assertIsNotNone(held) + if writable: + continue + self.assertEqual(explained, False) + for attempt in ( + lambda: cache.put(document, key, held), + lambda: cache.delete(document, key), + lambda: cache.put_envelope(document, cache.get_required_envelope(document, key)), + lambda: cache.put_envelopes(document, [cache.get_required_envelope(document, key)]), + lambda: cache.update(document, key, lambda value: value), + ): + with self.assertRaises(SchemaConflictError) as caught: + attempt() + refusal = caught.exception + self.assertEqual( + refusal.unread_slots, + UnreadSlots(_NAMES.get(type_name, type_name), carried, declared, occurrences, refusal.unread_slots.depth0_only), + ) + self.assertEqual(refusal.record_key, key) + message = str(refusal) + self.assertNotIn(SLOT, message) + self.assertIn(refusal.unread_slots.type_name, message) + self.assertIn(f"{carried} slots", message) + self.assertIn(f"declares {declared}", message) + self.assertEqual(path.read_bytes(), before) + self.assertTrue(cache.get(document, key) is held) + self.assertEqual(walked, 7) # eight rows, the variant not walked + + def test_a_key_no_store_holds_and_another_key_are_writable_while_a_marked_one_is_held(self) -> None: + path, cache = self._store("extra-slot-store.cc") + self.assertFalse(cache.is_writable(surface_document, "marked")) + self.assertTrue(cache.is_writable(surface_document, "nothing-here")) + cache.put(surface_document, "other", cache.get_required(surface_document, "clean")) + self.assertEqual(cache.get(surface_document, "other"), _surface_value()) + + def test_consent_lands_an_explained_mark_and_refuses_the_others(self) -> None: + path, cache = self._store("extra-slot-consent.cc") + self.assertTrue(cache.is_writable(declaring_document, "consented")) + cache.put(declaring_document, "consented", cache.get_required(declaring_document, "consented")) + [stored] = [e for e in SingleFileMessagePackBackingStore(path).pull_all() if e.key == "consented"] + self.assertEqual(stored.schema_id, "tests.union_surface.v2") + self.assertEqual(len(msgpack.unpackb(stored.payload, raw=False)), 7) # the consented slot is gone + self.assertTrue(cache.is_writable(declaring_document, "consented")) + for key in ("over-consent", "nested"): + self.assertFalse(cache.is_writable(declaring_document, key), key) + with self.assertRaises(SchemaConflictError): + cache.put(declaring_document, key, cache.get_required(declaring_document, key)) + + def test_a_reload_marks_again_and_a_write_of_a_clean_envelope_over_a_marked_key_is_refused(self) -> None: + path, cache = self._store("extra-slot-store.cc") + envelope = cache.get_required_envelope(surface_document, "clean") + with self.assertRaises(SchemaConflictError): + cache.put_envelope(surface_document, type(envelope)(**{**envelope.__dict__, "key": "marked"})) + cache.pull_all_backing_stores() + self.assertFalse(cache.is_writable(surface_document, "marked")) + + def test_a_written_envelope_that_carries_extra_slots_is_held_read_only(self) -> None: + path, cache = self._store("extra-slot-store.cc") + envelope = cache.get_required_envelope(surface_document, "clean") + carrying = type(envelope)(**{**envelope.__dict__, "key": "arrived", "payload": _vector("extra-slot-arm.msgpack")}) + cache.put_envelope(surface_document, carrying) + self.assertFalse(cache.is_writable(surface_document, "arrived")) + with self.assertRaises(SchemaConflictError): + cache.delete(surface_document, "arrived") + cache.pull_all_backing_stores() + self.assertFalse(cache.is_writable(surface_document, "arrived")) + + def test_a_batch_of_envelopes_that_carry_extra_slots_is_held_read_only(self) -> None: + path, cache = self._store("extra-slot-store.cc") + envelope = cache.get_required_envelope(surface_document, "clean") + batch = [ + type(envelope)(**{**envelope.__dict__, "key": key, "payload": _vector(file)}) + for key, file in (("b1", "extra-slot-arm.msgpack"), ("b2", "union-surface.msgpack")) + ] + cache.put_envelopes(surface_document, batch) + self.assertEqual([cache.is_writable(surface_document, key) for key in ("b1", "b2")], [False, True]) + + def test_a_mark_under_an_id_the_reader_does_not_list_compatible_is_not_consented(self) -> None: + unlisted = define_database_entry_type( + "tests.union_surface", + [database_entry_field("id", 0, codec=t.string)] + [database_entry_field(n, i, codec=c) for n, i, c in ( + ("version", 1, t.int32), ("root", 2, _parity._node), ("outcomes", 3, t.list(_parity.outcome)), + ("part", 4, t.nullable(_parity.item)), ("crafted", 5, t.nullable(_parity.crafted)), ("missing", 6, _parity._node))], + name="id", + schema_id="tests.union_surface.v2", + schema_name="tests.union_surface", + ) + path = self.tmp / "unlisted.cc" + _copy_store_without_variants(VECTORS / "extra-slot-consent.cc", path) + cache = _open(path, unlisted) + self.assertFalse(cache.is_writable(unlisted, "consented")) + + def test_a_written_envelope_is_judged_on_the_id_it_is_stored_under_so_a_reload_agrees(self) -> None: + path, cache = self._store("extra-slot-consent.cc") + arrived = next(e for e in SingleFileMessagePackBackingStore(path).pull_all() if e.key == "consented") + self.assertEqual(arrived.schema_id, "tests.union_surface.wide") + cache.put_envelope(declaring_document, dataclasses.replace(arrived, key="via-envelope")) + cache.put_envelopes(declaring_document, [dataclasses.replace(arrived, key="via-batch")]) + in_memory = [cache.is_writable(declaring_document, k) for k in ("via-envelope", "via-batch")] + cache.pull_all_backing_stores() + self.assertEqual([cache.is_writable(declaring_document, k) for k in ("via-envelope", "via-batch")], in_memory) + self.assertEqual(in_memory, [False, False]) + + def test_is_writable_agrees_with_the_write_for_a_key_a_store_carries_foreign(self) -> None: + path = self.tmp / "foreign.cc" + _copy_store_without_variants(VECTORS / "extra-slot-store.cc", path) + cache = _open(path, surface_document) # tests.slot_unit is not registered: its record is carried foreign + self.assertEqual([r.key for r in cache.foreign_records()], ["unit-arm"]) + self.assertFalse(cache.is_writable(surface_document, "unit-arm")) + with self.assertRaises(SchemaConflictError): + cache.put(surface_document, "unit-arm", _surface_value()) + with self.assertRaises(SchemaConflictError): + cache.delete(surface_document, "unit-arm") + + def test_every_key_of_a_batch_is_checked_wherever_the_marked_one_stands(self) -> None: + path, cache = self._store("extra-slot-store.cc") + before = path.read_bytes() + envelope = cache.get_required_envelope(surface_document, "clean") + fresh = dataclasses.replace(envelope, key="fresh") + marked = dataclasses.replace(envelope, key="marked") + for batch in ([fresh, marked], [marked, fresh], [fresh, dataclasses.replace(envelope, key="f2"), marked]): + with self.assertRaises(SchemaConflictError) as caught: + cache.put_envelopes(surface_document, batch) + self.assertEqual(caught.exception.record_key, "marked") + self.assertEqual(path.read_bytes(), before) + self.assertIsNone(cache.get(surface_document, "fresh")) + + def test_only_the_marks_that_hold_a_record_read_only_are_kept_by_key(self) -> None: + _, store = self._store("extra-slot-store.cc") + self.assertEqual({k: v[0] for k, v in store._state.unread.items()}, {"marked": surface_document.type, "unit-arm": "tests.slot_unit"}) + _, consent = self._store("extra-slot-consent.cc") + self.assertEqual(sorted(consent._state.unread), ["nested", "over-consent"]) # the consented mark is no state + + def test_an_arrival_with_no_catalog_entry_under_a_listed_id_is_not_consented(self) -> None: + wide = next(e for e in SingleFileMessagePackBackingStore(VECTORS / "extra-slot-consent.cc").pull_all() if e.key == "consented") + bare = dataclasses.replace(wide, catalog_entry=None) + + class Held: + def pull_all(self): + return [bare] + + def push(self, envelope): ... + + def push_all(self, envelopes): ... + + def delete(self, type, key): ... + + cache = CultCache.builder().register_document_type(declaring_document).add_generic_store(Held()).build() + cache.pull_all_backing_stores() + self.assertFalse(cache.is_writable(declaring_document, "consented")) + + def test_a_removal_is_refused_where_is_writable_says_no(self) -> None: + path, cache = self._store("extra-slot-store.cc") + before = path.read_bytes() + other = define_database_entry_type("tests.other", [database_entry_field("a", 0, codec=t.string)], name="a") + cache2 = _open(self.tmp / "extra-slot-store.cc", surface_document, slot_unit_document, other) + self.assertFalse(cache2.is_writable(other, "clean")) + with self.assertRaises(SchemaConflictError): + cache2.delete(other, "clean") + self.assertEqual(path.read_bytes(), before) + self.assertIsNotNone(cache2.get(surface_document, "clean")) + self.assertTrue(cache2.is_writable(other, "nothing-here")) + + def test_a_global_document_is_not_writable_at_a_key_other_than_the_global_one(self) -> None: + solo = define_database_entry_type("tests.solo", [database_entry_field("a", 0, codec=t.string)], global_document=True) + cache = CultCache.builder().register_document_type(solo).build() + self.assertFalse(cache.is_writable(solo, "elsewhere")) + with self.assertRaises(CultCacheError): + cache.put(solo, "elsewhere", {"a": "x"}) + with self.assertRaises(CultCacheError): + cache.delete(solo, "elsewhere") + self.assertTrue(cache.is_writable(solo, CultCache.GLOBAL_KEY)) + + def test_a_reload_replaces_the_marks_wholesale(self) -> None: + path, cache = self._store("extra-slot-store.cc") + self.assertFalse(cache.is_writable(surface_document, "marked")) + # The store is repaired by its owner: the marked record is rewritten clean. The reload must forget the old mark. + [snapshot] = list(msgpack.Unpacker(open(path, "rb"), raw=False)) + clean = next(r for r in snapshot[2] if r[0] == "clean") + for record in snapshot[2]: + if record[0] in ("marked", "unit-arm"): + record[3] = clean[3] if record[0] == "marked" else record[3] + snapshot[2] = [r for r in snapshot[2] if r[0] != "unit-arm"] + path.write_bytes(msgpack.packb(snapshot, use_bin_type=True)) + cache.pull_all_backing_stores() + self.assertTrue(cache.is_writable(surface_document, "marked")) + self.assertEqual(cache._state.unread, {}) + + def test_a_listed_id_whose_catalog_reports_no_extra_slot_does_not_consent(self) -> None: + wide = next(e for e in SingleFileMessagePackBackingStore(VECTORS / "extra-slot-consent.cc").pull_all() if e.key == "consented") + same = dataclasses.replace(wide, catalog_entry=declaring_document.catalog_entry()) # the stored entry has no slot past the reader's + + class Held: + def pull_all(self): + return [same] + + def push(self, envelope): ... + + def push_all(self, envelopes): ... + + def delete(self, type, key): ... + + cache = CultCache.builder().register_document_type(declaring_document).add_generic_store(Held()).build() + cache.pull_all_backing_stores() + self.assertFalse(cache.is_writable(declaring_document, "consented")) + + def test_a_record_stored_under_the_readers_own_id_is_never_consented(self) -> None: + own = dataclasses.replace( + next(e for e in SingleFileMessagePackBackingStore(VECTORS / "extra-slot-consent.cc").pull_all() if e.key == "consented"), + schema_id="tests.union_surface.v2", + ) + wider = dataclasses.replace(own.catalog_entry, schema_id="tests.union_surface.v2", compatible_schema_ids=("tests.union_surface.v2",)) + listing = dataclasses.replace( + declaring_document, schema_id="tests.union_surface.v2", compatible_schema_ids=("tests.union_surface.wide", "tests.union_surface.v2") + ) + + class Held: + def pull_all(self): + return [dataclasses.replace(own, catalog_entry=wider)] + + def push(self, envelope): ... + + def push_all(self, envelopes): ... + + def delete(self, type, key): ... + + cache = CultCache.builder().register_document_type(listing).add_generic_store(Held()).build() + cache.pull_all_backing_stores() + self.assertFalse(cache.is_writable(listing, "consented")) + + def test_a_removal_by_the_owner_of_a_key_held_as_another_type_removes_it_as_a_write_would_replace_it(self) -> None: + a = define_database_entry_type( + "tests.owned_a", [database_entry_field("a", 0, codec=t.string)], name="a", indexes={"by_a": "a"} + ) + b = define_database_entry_type( + "tests.owned_b", + [database_entry_field("a", 0, codec=t.string)], + compatible_schema_ids=(a.catalog_entry().schema_id,), + ) + path = self.tmp / "owned.cc" + writer = _open(path, a) + writer.put(a, "k", {"a": "x"}) + writer.put(a, "other", {"a": "y"}) + cache = _open(path, a, b) + self.assertTrue(cache.is_writable(b, "k")) + cache.delete(b, "k") + self.assertIsNone(cache.get(a, "k")) + self.assertIsNone(cache.get_by_index(a, "by_a", "x")) + self.assertEqual([e.key for e in SingleFileMessagePackBackingStore(path).pull_all()], ["other"]) + self.assertTrue(cache.is_writable(b, "k")) + + def test_an_owners_removal_is_one_store_call_so_the_cache_and_the_store_agree_when_it_fails(self) -> None: + a = define_database_entry_type("tests.one_a", [database_entry_field("a", 0, codec=t.string)], name="a") + b = define_database_entry_type( + "tests.one_b", [database_entry_field("a", 0, codec=t.string)], compatible_schema_ids=(a.catalog_entry().schema_id,) + ) + path = self.tmp / "one.cc" + _open(path, a).put(a, "k", {"a": "x"}) + + class SecondDeleteFails(SingleFileMessagePackBackingStore): + calls = 0 + + def delete(self, type, key): + self.calls += 1 + if self.calls > 1: + raise OSError("second delete") + super().delete(type, key) + + store = SecondDeleteFails(path) + cache = CultCache.builder().register_document_type(a).register_document_type(b).add_generic_store(store).build() + cache.pull_all_backing_stores() + cache.delete(b, "k") + self.assertEqual(store.calls, 1) + self.assertIsNone(cache.get(a, "k")) + self.assertEqual(SingleFileMessagePackBackingStore(path).pull_all(), []) + + def test_writing_another_record_and_flushing_carries_the_marked_one_unchanged(self) -> None: + path, cache = self._store("extra-slot-store.cc") + original = {e.key: e for e in SingleFileMessagePackBackingStore(path).pull_all()} + cache.put(surface_document, "clean", {**_surface_value(), "version": 9}) + after = {e.key: e for e in SingleFileMessagePackBackingStore(path).pull_all()} + for key in ("marked", "unit-arm"): + self.assertEqual(after[key].payload, original[key].payload, key) + self.assertEqual(after[key].schema_id, original[key].schema_id, key) + self.assertEqual(after[key].stored_at, original[key].stored_at, key) + self.assertNotEqual(after["clean"].payload, original["clean"].payload) + self.assertEqual(after["marked"].catalog_entry, original["marked"].catalog_entry) + + def test_the_vector_store_loads_under_the_reference_documents_and_marks_nothing_else(self) -> None: + path, cache = self._store("extra-slot-store.cc") + self.assertTrue(cache.is_writable(surface_document, "clean")) + self.assertFalse(cache.is_writable(surface_document, "marked")) + self.assertFalse(cache.is_writable(slot_unit_document, "unit-arm")) + self.assertEqual( + cache.get(slot_unit_document, "unit-arm"), {"name": "unit-arm", "outcome": UnionArm("Denied")} + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/cultcache-py/tests/test_union_parity.py b/packages/cultcache-py/tests/test_union_parity.py index 451cd030..1e19ce8d 100644 --- a/packages/cultcache-py/tests/test_union_parity.py +++ b/packages/cultcache-py/tests/test_union_parity.py @@ -261,7 +261,9 @@ class UnionSurfaceVectorTests(unittest.TestCase): else: read_only += 1 self.assertEqual(outcome_kind, "read-only", file) - self.assertEqual(surface_document.decode_payload(payload), _surface_value(), file) + value, mark = surface_document.decode_payload_marked(payload) + self.assertEqual(value, _surface_value(), file) + self.assertIsNotNone(mark, file) # read past and marked: tests/test_slot_readonly.py pins what the mark holds self.assertEqual((refused, read_only), (5, 4)) @@ -526,7 +528,9 @@ class BoundsVectorTests(unittest.TestCase): surface_document.encode_payload(surface_document.decode_payload(payload)), _vector(detail), file ) elif outcome_kind == "read-only": - self.assertEqual(surface_document.decode_payload(payload), _surface_value(), file) + value, mark = surface_document.decode_payload_marked(payload) + self.assertEqual(value, _surface_value(), file) + self.assertIsNotNone(mark, file) else: with self.assertRaises(ValueError, msg=file) as caught: surface_document.decode_payload(payload) From 09b825d68939b66a633b2ad05654bf970e15f64a Mon Sep 17 00:00:00 2001 From: Libby Date: Thu, 12 Nov 2026 04:44:03 +0000 Subject: [PATCH 47/93] Rust: a record with unread slots is held read-only (slot-readonly-rs) (#127) --- packages/cultcache-rs/CHANGELOG.md | 8 + packages/cultcache-rs/README.md | 11 + .../cultcache-rs-derive/src/lib.rs | 18 +- packages/cultcache-rs/src/lib.rs | 252 ++++++++- packages/cultcache-rs/tests/common/mod.rs | 235 +++++++++ packages/cultcache-rs/tests/slot_readonly.rs | 493 ++++++++++++++++++ packages/cultcache-rs/tests/union_parity.rs | 234 +-------- packages/cultmesh-rs/src/lib.rs | 53 ++ packages/cultnet-rs/src/replication.rs | 1 + 9 files changed, 1063 insertions(+), 242 deletions(-) create mode 100644 packages/cultcache-rs/tests/common/mod.rs create mode 100644 packages/cultcache-rs/tests/slot_readonly.rs diff --git a/packages/cultcache-rs/CHANGELOG.md b/packages/cultcache-rs/CHANGELOG.md index ed7cd60a..353facea 100644 --- a/packages/cultcache-rs/CHANGELOG.md +++ b/packages/cultcache-rs/CHANGELOG.md @@ -8,6 +8,13 @@ only in git history. ### Breaking +Semver: this is a breaking change in the 0.x line (0.3.x to 0.4.0). + +- A record whose payload carries unread slots is read-only. A payload with a slot past its type's declared slots, at any depth (record, nested record, list element, union arm, unit arm), still decodes, and every write or removal of its key is now refused before any store is touched: `put`, `put_envelope`, `put_raw_envelope`, `put_prepared_batch` (the whole batch) and `delete`, so `update` too. The refusal is `SchemaConflictError` with the new `unread_slots`; its message names the type and both slot counts and never a slot value. Holders of such a record used to overwrite it and drop the extra slots silently. The payload nesting refusal still runs first. A held payload this build cannot read at all (an arm it lacks from a newer writer, or a corrupt record) is read-only too: every write or removal is refused with an error naming the type and key (a newer writer's record is read with that build; a corrupt one is restored from a backup or removed at the store itself, as no cache call removes it), and `is_writable` answers false. `load_soa` is a write of every held key it omits or changes, so it refuses to drop or replace a read-only record. `is_writable` also answers false for a key a store carries as a foreign record and for a type other than the one holding the key, as the writes do. Each write over a held key decodes the held payload once to ask (about 40% of a `put` of a 200,000-element record, measured in release); keys nothing holds pay nothing. +- cultmesh-rs `pull_rudp_catalog_snapshot` applies documents one by one with `put_raw_envelope`, so a snapshot document for a key the node holds read-only ends the pull at that document and the documents before it stay applied. C# applies a raw snapshot as one cache commit, so there a refusal applies nothing: this is a divergence, owed by follow-up `mesh-snapshot-atomic` (stage the pull through `put_prepared_batch`). +- `SchemaConflictError` gains the public field `unread_slots: Option`; code that builds it with a struct literal must set it (`None`). +- A unit arm with slots (`[N, [extra...]]`) decodes as the unit arm and marks the record, where the derive refused it. +- The registration map's value changes from the schema name to a private struct holding the schema name and the unread-slots check. - A single-file store written as `cultcache.store.v3` is read and written: a store the C# runtime marked v3 (it holds a record with an element id: an owned list element with a `[CultElementId]` member) keeps its header here, and is carried through every rewrite with its ids; this runtime never drops the mark. A runtime from before this change refuses a v3 store by name. Rolling back to an older release after a store has been written as v3 is one-way: there is no carry-back, so restore from a copy taken before upgrading. - A write over a file the reader refuses (a zero-byte file, a truncated store, a stamp this runtime does not read) refuses with `CultCacheStoreUnreadable` and leaves the file as it is. A whole-store write used to replace such a file. This reverses 0.3.1, where `push_all` replaced a file with no readable stamp (healing it); the stamp rule now has one owner, the reader every write asks under the lock, and it accepts v1 and v3 (0.3.1 refused v3). - `#[cultcache(index)]` means unique, as in C# `RefuseIndexSharing`: within one type and one alias no two records hold one non-blank value. A put, batch, push or load that would make two records hold a value is refused, naming both keys and the alias, and a store that already holds a duplicate does not open. A grouping index is no longer expressible; a consumer that relied on shared values must drop the `index` and look up by another way. @@ -22,6 +29,7 @@ only in git history. ### Added +- `UnreadSlots { type_name, slot_count, declared_count, occurrences }`, `decode_payload_marked` (the decode that returns the mark) and `CultCache::is_writable(type, key)`. The derives' slot visitors note into a thread-local scope the decode entry opens (`__enter_slots`, `__note_unread_slots`, `__UnitArmSlots`, doc-hidden); a decode outside that entry notes nothing. - `CultCache::foreign_records()` and `ForeignRecord { key, schema_id, schema_name, stored_at }`: the records the last pull found that no registered type claims, sorted by key. - Lookups, matching the C# `GetByName`, `GetByIndex` and `GetGlobal`. `#[cultcache(name)]` and `#[cultcache(index)]` / `#[cultcache(index = "alias")]` on `DatabaseEntry` fields and `#[cultcache(global)]` on the struct give `CultCache::get_by_name`, `get_by_index` and `get_global`. An empty name and a blank index value are not found; records of one type sharing a name find the one with the smallest key (an index value is unique, below). A second record of a global type under another key is refused on every write path and on load with `CultCacheSecondGlobal`. Two `name` members, or two members with one index alias, are refused at compile time. A member's text comes from `CultLookupValue` (`String`, `Option` of one, integers, `bool`, `char`). - `SchemaConflictError`, the typed refusal of a write the catalog cannot describe: records of different types under one schema id, or an arrived schema and a registered one that share an id and disagree on the schema name. A whole-store write (`push_all`) refuses with it, names the schema id, the names involved and a record key, writes nothing and leaves the file as it is. A caller tells it apart with `error.downcast_ref::()`. diff --git a/packages/cultcache-rs/README.md b/packages/cultcache-rs/README.md index 8a8b8f5e..ed9701d5 100644 --- a/packages/cultcache-rs/README.md +++ b/packages/cultcache-rs/README.md @@ -236,6 +236,17 @@ assert_eq!(rmp_serde::from_slice::(&bytes)?, tree); # Ok::<(), Box>(()) ``` +## Records written by a newer build + +A payload that carries a slot past the slots its type declares, in a record, a nested record, a list element or a union +arm (a unit arm declares none), still decodes: the extra slots are skipped. The cache then holds that record read-only, +because writing it would drop what the newer writer stored. `put`, `put_envelope`, `put_raw_envelope`, +`put_prepared_batch` and `delete` of its key are refused with `SchemaConflictError`, whose `unread_slots` names the type +and the slot counts and never a value; `CultCache::is_writable(type, key)` answers the same check. An untouched +read-only record keeps its bytes through every other write. A short payload (missing trailing slots) is not marked, and +a payload nested past `MAX_PAYLOAD_DEPTH` is refused before anything is read. `decode_payload_marked` is the decode that +returns the `UnreadSlots` mark. This runtime has no compatible schema ids, so no mark is consented. + ## Structure Of Arrays The C# CultCache hot path exposes CPU-local SoA scans as: diff --git a/packages/cultcache-rs/cultcache-rs-derive/src/lib.rs b/packages/cultcache-rs/cultcache-rs-derive/src/lib.rs index c7f9a3a2..b426ae01 100644 --- a/packages/cultcache-rs/cultcache-rs-derive/src/lib.rs +++ b/packages/cultcache-rs/cultcache-rs-derive/src/lib.rs @@ -381,6 +381,7 @@ fn slot_codec(ident: &syn::Ident, slots: &[CultCacheSlot]) -> proc_macro2::Token where __A: ::serde::de::SeqAccess<'de>, { + let __ticket = ::cultcache_rs::__enter_slots(); #(#option_declarations)* let mut index = 0usize; while index <= #max_slot { @@ -390,7 +391,15 @@ fn slot_codec(ident: &syn::Ident, slots: &[CultCacheSlot]) -> proc_macro2::Token } index += 1; } - while let ::std::option::Option::Some(_) = seq.next_element::<::serde::de::IgnoredAny>()? {} + // The slots past the declared ones are skipped and counted: the decode entry marks the record + // that carried them read-only. + let mut __extra = 0usize; + while seq.next_element::<::serde::de::IgnoredAny>()?.is_some() { + __extra += 1; + } + if __extra > 0 { + ::cultcache_rs::__note_unread_slots(__ticket, stringify!(#ident), #max_slot + 1 + __extra, #max_slot + 1); + } Ok(#ident { #(#build_fields),* }) @@ -567,10 +576,13 @@ fn union_codec(input: DeriveInput) -> syn::Result { quote! { #key => { match seq - .next_element::<::std::option::Option<[(); 0]>>() + .next_element::<::std::option::Option<::cultcache_rs::__UnitArmSlots>>() .map_err(|_| ::serde::de::Error::custom(#arity_message))? { - ::std::option::Option::Some(::std::option::Option::Some(_)) => #ident::#variant, + ::std::option::Option::Some(::std::option::Option::Some(slots)) => { + slots.note(stringify!(#variant)); + #ident::#variant + } ::std::option::Option::Some(::std::option::Option::None) => { return ::std::result::Result::Err(::serde::de::Error::custom( format!(#nil_slots_message, #key), diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index 686547f4..59ffa1af 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -800,6 +800,154 @@ pub fn decode_payload(payload: &[u8]) -> Result< decode_grown(payload).map_err(opaque_decode_error) } +/// What a payload decode found past the slots this build declares, at any depth: the first type read with extra slots, +/// how many slots it carried against how many it declares, and how many objects in the payload did. It holds no slot +/// value, so a refusal can name it. A record whose payload carries it is read-only in the cache +/// ([`CultCache::is_writable`]); this runtime has no compatible schema ids, so every mark is unconsented. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct UnreadSlots { + /// The first type read with extra slots (a unit arm is named by its variant). + pub type_name: String, + /// The slots that object carried. + pub slot_count: usize, + /// The slots this build declares for it. + pub declared_count: usize, + /// The objects in the payload that carried extra slots. + pub occurrences: usize, +} + +/// The open mark scope of a decode: how many slot objects it has entered, and the mark of the first entered one that +/// carried extra slots (the reference names the first type read, which is the outermost: it is entered before its members). +#[derive(Default)] +struct MarkScope { + entered: usize, + mark: Option<(usize, UnreadSlots)>, +} + +thread_local! { + /// The open mark scope of a decode on this thread: `None` when no decode entry has one open, so a decode through + /// serde alone notes nothing. The slot visitors the derives write note into it. + static UNREAD_SLOTS: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; +} + +/// The derives' sensor, first half: a slot object is entered. Its ticket orders it among the objects of the decode. +#[doc(hidden)] +pub fn __enter_slots() -> usize { + UNREAD_SLOTS.with(|scope| { + scope.borrow_mut().as_mut().map_or(0, |open| { + open.entered += 1; + open.entered + }) + }) +} + +/// The derives' sensor, second half: the object entered as `ticket`, of `type_name`, carried `carried` slots where this +/// build declares `declared`. +#[doc(hidden)] +pub fn __note_unread_slots(ticket: usize, type_name: &str, carried: usize, declared: usize) { + UNREAD_SLOTS.with(|scope| { + let mut scope = scope.borrow_mut(); + let Some(open) = scope.as_mut() else { + return; + }; + let occurrences = open.mark.as_ref().map_or(0, |(_, found)| found.occurrences) + 1; + if open.mark.as_ref().is_none_or(|(first, _)| ticket < *first) { + let named = UnreadSlots { type_name: type_name.to_string(), slot_count: carried, declared_count: declared, occurrences }; + open.mark = Some((ticket, named)); + } else if let Some((_, found)) = open.mark.as_mut() { + found.occurrences = occurrences; + } + }); +} + +/// A unit arm is a zero-slot record: its slots array is read and counted, not refused. +#[doc(hidden)] +pub struct __UnitArmSlots { + ticket: usize, + extra: usize, +} + +impl __UnitArmSlots { + /// Notes the arm if its array held any element. + pub fn note(self, variant: &str) { + if self.extra > 0 { + __note_unread_slots(self.ticket, variant, self.extra, 0); + } + } +} + +impl<'de> serde::Deserialize<'de> for __UnitArmSlots { + fn deserialize>(deserializer: D) -> std::result::Result { + struct Count; + impl<'de> serde::de::Visitor<'de> for Count { + type Value = __UnitArmSlots; + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a slots array") + } + fn visit_seq>(self, mut seq: A) -> std::result::Result { + let ticket = __enter_slots(); + let mut extra = 0usize; + while seq.next_element::()?.is_some() { + extra += 1; + } + Ok(__UnitArmSlots { ticket, extra }) + } + } + deserializer.deserialize_seq(Count) + } +} + +/// The scope one marked decode opens on this thread; closed when it ends, whatever the decode did. A marked decode is +/// never re-entered (only the cache's own checks open one), so scopes do not nest. +struct UnreadScope; + +impl UnreadScope { + fn open() -> Self { + UNREAD_SLOTS.with(|scope| *scope.borrow_mut() = Some(MarkScope::default())); + Self + } + + fn mark(&self) -> Option { + UNREAD_SLOTS.with(|scope| scope.borrow_mut().as_mut().and_then(|open| open.mark.take()).map(|(_, mark)| mark)) + } +} + +impl Drop for UnreadScope { + fn drop(&mut self) { + UNREAD_SLOTS.with(|scope| *scope.borrow_mut() = None); + } +} + +/// [`decode_payload`] with the mark: the same depth check and grown decode, inside a scope the derives' slot +/// visitors note into. A payload past the nesting limit is refused before the decode, never marked. +pub fn decode_payload_marked(payload: &[u8]) -> Result<(T, Option)> { + check_payload_depth(payload)?; + let scope = UnreadScope::open(); + let value = decode_grown(payload).map_err(opaque_decode_error)?; + Ok((value, scope.mark())) +} + +/// The registered check of a held payload: monomorphized by [`CultCache::register_entry_type`] so the untyped write +/// entries can decode a payload they cannot name. +/// A payload the type cannot decode at all (an arm this build lacks, a corrupt record) is an error here, and the held +/// record is read-only: the cache cannot tell what a writer of another build stored in it. +fn unread_slots_of(payload: &[u8]) -> Result> { + decode_payload_marked::(payload).map(|(_, mark)| mark) +} + +/// What the cache finds in a held payload when asked to write or remove its key. +enum HeldFinding { + Free, + Marked(UnreadSlots), + Unreadable(anyhow::Error), +} + +/// What a registered type is to the cache: its schema name and how to read the slots a held payload carries. +struct Definition { + schema_name: &'static str, + unread_slots: fn(&[u8]) -> Result>, +} + /// The nesting bound for an envelope admitted without a typed decode, from the same walk that /// [`decode_payload`] runs first. Names the entry's type and key, never the payload. fn require_payload_depth(entry: &CultCacheEnvelope) -> Result<()> { @@ -938,10 +1086,26 @@ pub struct SchemaConflictError { pub schema_id: String, pub schema_names: Vec, pub record_key: String, + /// Set when the refusal is a write or removal of a record held read-only for the slots its payload carries past + /// this build's; `None` for every other refusal. + pub unread_slots: Option, } impl std::fmt::Display for SchemaConflictError { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Some(unread) = &self.unread_slots { + return write!( + formatter, + "{} record {} is read-only: {} was read with {} slots where this build declares {} ({} object(s) in the record). \ + Writing it would drop data a newer writer stored; read it with types that declare those slots, or write it from that build.", + self.schema_names.first().map_or("", String::as_str), + self.record_key, + unread.type_name, + unread.slot_count, + unread.declared_count, + unread.occurrences + ); + } write!( formatter, "schema id {:?} cannot describe record {:?}: {:?}", @@ -2738,7 +2902,7 @@ struct CultCacheStoreRegistration { } pub struct CultCache { - definitions: BTreeMap, + definitions: BTreeMap, /// The registered types declared `#[cultcache(global)]`. globals: BTreeSet, schema_name_definitions: BTreeMap, @@ -2825,8 +2989,8 @@ impl CultCache { "CultCache entry types must declare a non-empty type" )); } - if let Some(existing_schema) = self.definitions.get(T::TYPE) - && *existing_schema != T::SCHEMA_NAME + if let Some(existing) = self.definitions.get(T::TYPE) + && existing.schema_name != T::SCHEMA_NAME { return Err(anyhow!( "CultCache already has a different definition registered for type {:?}", @@ -2875,7 +3039,7 @@ impl CultCache { } } } - self.definitions.insert(T::TYPE.to_string(), T::SCHEMA_NAME); + self.definitions.insert(T::TYPE.to_string(), Definition { schema_name: T::SCHEMA_NAME, unread_slots: unread_slots_of:: }); self.schema_name_definitions .insert(T::SCHEMA_NAME.to_string(), T::TYPE.to_string()); Ok(()) @@ -3036,6 +3200,60 @@ impl CultCache { Ok(Some(payload)) } + /// Whether a write or removal of `(type_id, key)` would be admitted: false when the held payload carries slots past + /// its registered type's declared slots, at any depth, or cannot be read by this build at all (the record is + /// read-only), when a store carries a foreign record at the key, or when another registered type holds it. True for a + /// key nothing holds. An unregistered type is an error. Advisory: the write entries ask the same checks themselves. + pub fn is_writable(&self, type_id: &str, key: &str) -> Result { + if !matches!(self.held_finding(type_id, key)?, HeldFinding::Free) { + return Ok(false); + } + let probe = CultCacheEnvelope { + key: key.to_string(), + r#type: type_id.to_string(), + payload: Vec::new(), + stored_at: String::new(), + schema_id: None, + }; + Ok(self.home_index(type_id).is_ok() && self.refuse_retype(&self.entries, &self.foreign, &probe).is_ok()) + } + + /// The one decode behind every write entry and [`CultCache::is_writable`]: the held envelope at `(type_id, key)` + /// read under the registered type's mark. + fn held_finding(&self, type_id: &str, key: &str) -> Result { + let Some(definition) = self.definitions.get(type_id) else { + bail!("No entry type registered for CultCache envelope type {type_id:?}"); + }; + let Some(held) = self.entries.get(&entry_id_parts(type_id, key)) else { + return Ok(HeldFinding::Free); + }; + Ok(match (definition.unread_slots)(&held.payload) { + Ok(None) => HeldFinding::Free, + Ok(Some(unread)) => HeldFinding::Marked(unread), + Err(error) => HeldFinding::Unreadable(error), + }) + } + + /// Refuses a write or removal of a record held read-only, before any store is touched: for the slots its payload + /// carries past this build's, or because this build cannot read the payload at all. + fn refuse_unread_slots(&self, type_id: &str, key: &str) -> Result<()> { + match self.held_finding(type_id, key)? { + HeldFinding::Free => Ok(()), + HeldFinding::Unreadable(error) => Err(error.context(format!( + "CultCache entry {type_id:?} at key {key:?} is held read-only: this build cannot read its payload. A newer writer's record is read with that build; a corrupt one is restored from a backup or removed at the store itself, not through the cache" + ))), + HeldFinding::Marked(unread) => { + let held = &self.entries[&entry_id_parts(type_id, key)]; + Err(anyhow::Error::new(SchemaConflictError { + schema_id: schema_identity(held).to_string(), + schema_names: vec![self.definitions[type_id].schema_name.to_string()], + record_key: key.to_string(), + unread_slots: Some(unread), + })) + } + } + } + pub fn get_required(&self, key: &str) -> Result { self.get::(key)? .ok_or_else(|| anyhow!("CultCache has no {:?} entry at key {:?}", T::TYPE, key)) @@ -3166,6 +3384,7 @@ impl CultCache { pub fn put(&mut self, key: impl Into, value: &T) -> Result { let (entry, parsed) = self.prepare_entry(key, value)?; + self.refuse_unread_slots(&entry.r#type, &entry.key)?; self.refuse_second_global(&self.entries, &entry.r#type, &entry.key)?; self.refuse_retype(&self.entries, &self.foreign, &entry)?; let change = self.judge_unique(&[&entry])?; @@ -3266,6 +3485,7 @@ impl CultCache { )); } require_payload_depth(entry)?; + self.refuse_unread_slots(&entry.r#type, &entry.key)?; self.refuse_claimed_id(entry)?; let Some(home) = self.home_index(&entry.r#type)? else { continue; @@ -3308,6 +3528,7 @@ impl CultCache { pub fn put_envelope(&mut self, entry: CultCacheEnvelope) -> Result { let parsed = self.validate_envelope::(&entry)?; + self.refuse_unread_slots(&entry.r#type, &entry.key)?; self.refuse_claimed_id(&entry)?; self.refuse_second_global(&self.entries, &entry.r#type, &entry.key)?; self.refuse_retype(&self.entries, &self.foreign, &entry)?; @@ -3345,6 +3566,7 @@ impl CultCache { )); } require_payload_depth(&entry)?; + self.refuse_unread_slots(&entry.r#type, &entry.key)?; self.refuse_claimed_id(&entry)?; self.refuse_second_global(&self.entries, &entry.r#type, &entry.key)?; self.refuse_retype(&self.entries, &self.foreign, &entry)?; @@ -3400,6 +3622,7 @@ impl CultCache { pub fn delete(&mut self, key: &str) -> Result { self.require_entry_type::()?; + self.refuse_unread_slots(T::TYPE, key)?; let id = entry_id_parts(T::TYPE, key); let Some(entry) = self.entries.get(&id).cloned() else { if let Some((_, foreign)) = self.foreign.iter().find(|(_, foreign)| foreign.key == key) { @@ -3427,7 +3650,9 @@ impl CultCache { /// Replaces the cache's view with the table's records. Every record is /// checked (registered type, and a home store once any store is attached) /// into a local image first, so a refused load admits nothing and leaves - /// the prior view intact. Loading writes no store. + /// the prior view intact. Loading writes no store, but a held record that is read-only (unread slots, or a payload + /// this build cannot read) may not be omitted or replaced by the table: the next batch would lay the image over the + /// store. pub fn load_soa(&mut self, table: CultCacheSoaTable) -> Result<()> { let mut loaded = BTreeMap::new(); for entry in table.into_envelopes()? { @@ -3442,6 +3667,12 @@ impl CultCache { self.refuse_retype(&loaded, &self.foreign, &entry)?; loaded.insert(entry_id(&entry), entry); } + // A load is a write of every held key it omits or changes: the one check refuses those held read-only. + for ((type_id, key), held) in &self.entries { + if loaded.get(&(type_id.clone(), key.clone())).is_none_or(|incoming| incoming.payload != held.payload) { + self.refuse_unread_slots(type_id, key)?; + } + } self.refuse_second_globals(&loaded)?; let holders = self.judge_image(&loaded)?; self.entries = loaded; @@ -3518,7 +3749,7 @@ impl CultCache { } for (slot, keys) in &ordered { if let [first, second, ..] = keys.as_slice() { - let schema = self.definitions.get(&slot.0).copied().unwrap_or(&slot.0); + let schema = self.definitions.get(&slot.0).map_or(slot.0.as_str(), |definition| definition.schema_name); let violation = [slot.1.as_str(), first.as_str(), second.as_str(), schema]; if least.is_none_or(|best| violation_cmp(&violation, &best).is_lt()) { least = Some(violation); @@ -3553,7 +3784,7 @@ impl CultCache { .find(|held_key| held_key.as_str() != key); match held_other { Some(existing_key) => Err(CultCacheSecondGlobal { - schema_name: self.definitions.get(type_id).copied().unwrap_or(type_id).to_string(), + schema_name: self.definitions.get(type_id).map_or(type_id, |definition| definition.schema_name).to_string(), existing_key: existing_key.clone(), key: key.to_string(), } @@ -3597,6 +3828,7 @@ impl CultCache { schema_id: schema_identity(entry).to_string(), schema_names: vec![resolved, entry.r#type.clone()], record_key: entry.key.clone(), + unread_slots: None, })), _ => Ok(()), } @@ -3611,7 +3843,7 @@ impl CultCache { fn require_entry_type(&self) -> Result<()> { match self.definitions.get(T::TYPE) { - Some(schema_name) if *schema_name == T::SCHEMA_NAME => Ok(()), + Some(definition) if definition.schema_name == T::SCHEMA_NAME => Ok(()), _ => Err(anyhow!( "CultCache entry type {:?} is not registered on this cache instance", T::TYPE @@ -3693,6 +3925,7 @@ fn encode_store_snapshot(entries: &[CultCacheEnvelope], disk: &DiskStore) -> Res schema_id, schema_names: vec![existing_type, entry.r#type.clone()], record_key: entry.key.clone(), + unread_slots: None, })); } } @@ -3710,6 +3943,7 @@ fn encode_store_snapshot(entries: &[CultCacheEnvelope], disk: &DiskStore) -> Res schema_id, schema_names: vec![arrived_type, document_type], record_key: key, + unread_slots: None, })); } } @@ -3846,6 +4080,7 @@ fn foreign_conflict(carried: &CultCacheEnvelope, written_type: &str) -> anyhow:: schema_id: schema_identity(carried).to_string(), schema_names: vec![carried.r#type.clone(), written_type.to_string()], record_key: carried.key.clone(), + unread_slots: None, }) } @@ -3856,6 +4091,7 @@ fn retype_conflict(held: &CultCacheEnvelope, written: &CultCacheEnvelope) -> any schema_id: schema_identity(written).to_string(), schema_names: vec![held.r#type.clone(), written.r#type.clone()], record_key: written.key.clone(), + unread_slots: None, }) } diff --git a/packages/cultcache-rs/tests/common/mod.rs b/packages/cultcache-rs/tests/common/mod.rs new file mode 100644 index 00000000..3f893c0f --- /dev/null +++ b/packages/cultcache-rs/tests/common/mod.rs @@ -0,0 +1,235 @@ +//! The union-parity vectors' types and constructed value, shared by the test files that walk them. +#![allow(dead_code)] + +use std::path::PathBuf; + +use cultcache_rs::CultSlots; +use cultcache_rs::CultUnion; +use cultcache_rs::DatabaseEntry; + + +#[derive(Clone, Debug, PartialEq, CultUnion)] +pub enum Node { + #[cultcache(key = 0)] + Box(BoxArm), + #[cultcache(key = 1)] + Text(TextArm), + #[cultcache(key = 2)] + Use(UseArm), + #[cultcache(key = 5)] + Image(ImageArm), + #[cultcache(key = 6)] + Wrap(WrapArm), +} + +#[derive(Clone, Debug, PartialEq, CultSlots)] +pub struct WrapArm { + #[cultcache(key = 0)] + pub inner: Option>, + #[cultcache(key = 1)] + pub id: String, +} + +#[derive(Clone, Debug, PartialEq, CultSlots)] +pub struct BoxArm { + #[cultcache(key = 0)] + pub id: String, + #[cultcache(key = 1)] + pub children: Vec, +} + +#[derive(Clone, Debug, PartialEq, CultSlots)] +pub struct TextArm { + #[cultcache(key = 0)] + pub id: String, + #[cultcache(key = 1)] + pub content: String, +} + +#[derive(Clone, Debug, PartialEq, CultSlots)] +pub struct UseArm { + #[cultcache(key = 0)] + pub id: String, + #[cultcache(key = 1)] + pub composition: Composition, + #[cultcache(key = 2)] + pub slots: Vec>, +} + +// Slot 2 is retired: a gap written nil. +#[derive(Clone, Debug, PartialEq, CultSlots)] +pub struct ImageArm { + #[cultcache(key = 0)] + pub id: String, + #[cultcache(key = 1)] + pub uri: String, + #[cultcache(key = 3)] + pub alt: String, +} + +#[derive(Clone, Debug, PartialEq, CultSlots)] +pub struct Composition { + #[cultcache(key = 0)] + pub name: String, + #[cultcache(key = 1)] + pub body: Option>, +} + +#[derive(Clone, Debug, PartialEq, CultUnion)] +pub enum Outcome { + #[cultcache(key = 0)] + Pending(PendingArm), + #[cultcache(key = 1)] + Completed(CompletedArm), + #[cultcache(key = 2)] + Denied(IdArm), + #[cultcache(key = -2147483648)] + Floor(IdArm), + #[cultcache(key = 2147483647)] + Ceiling(IdArm), +} + +// The codec's unit arm (no members, written [key, []]), kept apart from the golden: a document list's arms carry ids. +#[derive(Clone, Debug, PartialEq, CultUnion)] +pub enum UnitOutcome { + #[cultcache(key = 2)] + Denied, + #[cultcache(key = -2147483648)] + Floor, + #[cultcache(key = 2147483647)] + Ceiling, +} + +#[derive(Clone, Debug, PartialEq, CultSlots)] +pub struct PendingArm { + #[cultcache(key = 0)] + pub message: String, + #[cultcache(key = 1)] + pub id: String, +} + +// An arm with no members of its own: just its element id. +#[derive(Clone, Debug, PartialEq, CultSlots)] +pub struct IdArm { + #[cultcache(key = 0)] + pub id: String, +} + +#[derive(Clone, Debug, PartialEq, CultSlots)] +pub struct CompletedArm { + #[cultcache(key = 0)] + pub source_version: i32, + #[cultcache(key = 1)] + pub id: String, +} + +#[derive(Clone, Debug, PartialEq, CultSlots)] +pub struct NamedArm { + #[cultcache(key = 0)] + pub name: String, +} + +#[derive(Clone, Debug, PartialEq, CultSlots)] +pub struct UsesArm { + #[cultcache(key = 0)] + pub name: String, + #[cultcache(key = 1)] + pub uses: i32, +} + +#[derive(Clone, Debug, PartialEq, CultSlots)] +pub struct CountArm { + #[cultcache(key = 0)] + pub name: String, + #[cultcache(key = 1)] + pub count: i32, +} + +#[derive(Clone, Debug, PartialEq, CultUnion)] +pub enum Item { + #[cultcache(key = 0)] + Gem(NamedArm), + #[cultcache(key = 1)] + Tool(UsesArm), + #[cultcache(key = 2)] + Part(CountArm), +} + +// The same arms as Item under its own keys: key 2 is unknown to it. +#[derive(Clone, Debug, PartialEq, CultUnion)] +pub enum CraftedItem { + #[cultcache(key = 0)] + Tool(UsesArm), + #[cultcache(key = 1)] + Part(CountArm), +} + +#[derive(Clone, Debug, PartialEq, DatabaseEntry)] +#[cultcache(type = "tests.union_surface", schema = "tests.union_surface.v1")] +pub struct UnionSurface { + #[cultcache(key = 0)] + pub id: String, + #[cultcache(key = 1)] + pub version: i32, + #[cultcache(key = 2)] + pub root: Option, + #[cultcache(key = 3)] + pub outcomes: Vec, + #[cultcache(key = 4)] + pub part: Option, + #[cultcache(key = 5)] + pub crafted: Option, + #[cultcache(key = 6)] + pub missing: Option, +} + +pub fn text(id: &str, content: &str) -> Node { + Node::Text(TextArm { id: id.into(), content: content.into() }) +} + +pub fn image(id: &str, uri: &str, alt: &str) -> Node { + Node::Image(ImageArm { id: id.into(), uri: uri.into(), alt: alt.into() }) +} + +pub fn boxed(id: &str, children: Vec) -> Node { + Node::Box(BoxArm { id: id.into(), children }) +} + +pub fn constructed() -> UnionSurface { + let inner = boxed("0000000000d0", vec![text("0000000000d1", "x"), image("0000000000d2", "uri://a", "alt")]); + UnionSurface { + id: "surface".into(), + version: 3, + root: Some(boxed( + "0000000000c0", + vec![ + text("0000000000c1", "hello"), + Node::Use(UseArm { + id: "0000000000c2".into(), + composition: Composition { name: "comp".into(), body: Some(std::boxed::Box::new(inner)) }, + slots: vec![Some(text("0000000000e1", "slot")), None, Some(boxed("0000000000e2", vec![]))], + }), + image("0000000000c3", "uri://b", ""), + ], + )), + outcomes: vec![ + Outcome::Pending(PendingArm { message: "wait".into(), id: "0000000000a1".into() }), + Outcome::Completed(CompletedArm { source_version: 7, id: "0000000000a2".into() }), + Outcome::Denied(id_arm("0000000000a3")), + ], + part: Some(Item::Tool(UsesArm { name: "hammer".into(), uses: 12 })), + crafted: Some(CraftedItem::Part(CountArm { name: "gear".into(), count: 4 })), + missing: None, + } +} + +pub fn id_arm(id: &str) -> IdArm { + IdArm { id: id.into() } +} + +pub fn vector(name: &str) -> Vec { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../tests/vectors/union-parity") + .join(name); + std::fs::read(&path).unwrap_or_else(|error| panic!("{}: {error}", path.display())) +} diff --git a/packages/cultcache-rs/tests/slot_readonly.rs b/packages/cultcache-rs/tests/slot_readonly.rs new file mode 100644 index 00000000..5bffcd9a --- /dev/null +++ b/packages/cultcache-rs/tests/slot_readonly.rs @@ -0,0 +1,493 @@ +//! A record whose payload carries unread slots at any depth is read-only: the C# reference's union-parity vectors +//! (README "Extra trailing slot" and "Slot stores") walked through the cache. This runtime has no compatible schema +//! ids, so every mark is unconsented and no `explained` outcome can arise. + +mod common; + +use std::path::Path; + +use common::*; +use cultcache_rs::CultCache; +use cultcache_rs::CultCacheEnvelope; +use cultcache_rs::DatabaseEntry; +use cultcache_rs::SchemaConflictError; +use cultcache_rs::SingleFileMessagePackBackingStore; +use cultcache_rs::UnreadSlots; +use cultcache_rs::decode_payload_marked; + +/// A row of `slot-outcomes.msgpack`: store, key, writable, explained, type, slots, declared, occurrences, flattenRefusedBy. +type SlotRow = (String, String, bool, Option, Option, Option, Option, Option, Option); + +const SLOT_VALUE: &str = "extra-slot"; + +/// `tests.slot_unit`, a document `[Name, Outcome: UnitOutcome]`. +#[derive(Clone, Debug, PartialEq, DatabaseEntry)] +#[cultcache(type = "tests.slot_unit", schema = "tests.slot_unit.v1")] +struct SlotUnit { + #[cultcache(key = 0)] + name: String, + #[cultcache(key = 1)] + outcome: UnitOutcome, +} + +/// Two declared slots, the second defaulted: a payload of one slot is short, not marked. +#[derive(Clone, Debug, PartialEq, DatabaseEntry)] +#[cultcache(type = "tests.shortened", schema = "tests.shortened.v1")] +struct Shortened { + #[cultcache(key = 0)] + id: String, + #[cultcache(key = 1, default)] + note: String, +} + +fn registered() -> CultCache { + let mut cache = CultCache::new(); + cache.register_entry_type::().unwrap(); + cache.register_entry_type::().unwrap(); + cache +} + +/// Any MessagePack value a store holds, enough to drop one record from a store and write the rest back. +#[derive(serde::Serialize, serde::Deserialize)] +#[serde(untagged)] +enum Packed { + Nil(()), + Bool(bool), + Int(i64), + Text(String), + Bin(#[serde(with = "serde_bytes")] Vec), + Array(Vec), +} + +/// The committed store without its `marked-variant` record. A variant is a five-slot record, which this runtime's +/// store reader refuses whole (`the_variant_row_...`), so the other rows are walked from a copy without it. +fn without_variant_row(store: &[u8]) -> Vec { + let Packed::Array(mut parts) = rmp_serde::from_slice(store).unwrap() else { panic!("a store is an array") }; + let Packed::Array(records) = parts.last_mut().unwrap() else { panic!("a store ends in its records") }; + let before = records.len(); + records.retain(|record| !matches!(record, Packed::Array(fields) if matches!(fields.first(), Some(Packed::Text(key)) if key == "marked-variant"))); + assert_eq!(records.len(), before - 1); + rmp_serde::to_vec(&parts).unwrap() +} + +/// A copy of a committed store, opened with both types registered; the vector itself is never written. The slot store +/// is read without its variant row. +fn open_copy(file: &str, dir: &Path) -> (CultCache, std::path::PathBuf) { + let path = dir.join(file); + let bytes = if file == "extra-slot-store.cc" { without_variant_row(&vector(file)) } else { vector(file) }; + std::fs::write(&path, bytes).unwrap(); + let mut cache = registered(); + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap(); + cache.pull_all_backing_stores().unwrap(); + (cache, path) +} + +fn envelope(key: &str, payload: Vec) -> CultCacheEnvelope { + CultCacheEnvelope { + key: key.into(), + r#type: UnionSurface::TYPE.into(), + payload, + stored_at: "2026-01-01T00:00:00Z".into(), + schema_id: None, + } +} + +fn conflict(error: &anyhow::Error) -> &SchemaConflictError { + error.downcast_ref::().unwrap_or_else(|| panic!("not a schema conflict: {error:#}")) +} + +fn mark(type_name: &str, slot_count: usize, declared_count: usize, occurrences: usize) -> UnreadSlots { + UnreadSlots { type_name: type_name.into(), slot_count, declared_count, occurrences } +} + +/// The marks the README table pins, by `detail`. The reference names an arm by its class; here an arm is the +/// `...Arm` struct the union wraps. +fn expected_mark(detail: &str) -> UnreadSlots { + match detail { + "record" | "depth" => mark("UnionSurface", 8, 7, 1), + "nested-record" => mark("Composition", 3, 2, 1), + "arm" => mark("BoxArm", 3, 2, 1), + "list-element" => mark("TextArm", 3, 2, 1), + other => panic!("unknown detail {other}"), + } +} + +fn assert_refused_with(error: &anyhow::Error, key: &str, expected: &UnreadSlots) { + let refusal = conflict(error); + assert_eq!(refusal.unread_slots.as_ref(), Some(expected)); + assert_eq!(refusal.record_key, key); + assert_eq!(refusal.schema_names.len(), 1); + let message = error.to_string(); + assert!(message.contains(&expected.type_name), "{message}"); + assert!(message.contains(&expected.slot_count.to_string()) && message.contains(&expected.declared_count.to_string()), "{message}"); + assert!(!message.contains(SLOT_VALUE), "a refusal echoed the slot value: {message}"); +} + +#[test] +fn every_read_only_vector_decodes_marked_and_is_not_writable() { + let rows: Vec<(String, String, String, Option, String)> = rmp_serde::from_slice(&vector("outcomes.msgpack")).unwrap(); + assert_eq!(rows.len(), 9); + let bounds: Vec = rmp_serde::from_slice(&vector("bounds-outcomes.msgpack")).unwrap(); + let mut cases: Vec<(String, String)> = rows + .into_iter() + .filter(|(_, outcome, ..)| outcome == "read-only") + .map(|(file, _, _, _, detail)| (file, detail)) + .collect(); + for entry in &bounds[1..] { + let fields: Vec<&str> = entry.as_array().unwrap().iter().filter_map(|field| field.as_str()).collect(); + if fields[1] == "read-only" { + cases.push((fields[0].to_string(), fields[3].to_string())); + } + } + assert_eq!(cases.len(), 5); + for (file, detail) in cases { + let payload = vector(&file); + let (value, found) = decode_payload_marked::(&payload).unwrap_or_else(|error| panic!("{file}: {error:#}")); + assert_eq!(value, constructed(), "{file}"); + assert_eq!(found, Some(expected_mark(&detail)), "{file}"); + + let mut cache = registered(); + let dir = tempfile::tempdir().unwrap(); + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(dir.path().join("s.cc"))).unwrap(); + cache.put_raw_envelope(envelope("k", payload.clone())).unwrap(); + assert_eq!(cache.get::("k").unwrap(), Some(constructed()), "{file}"); + assert!(!cache.is_writable(UnionSurface::TYPE, "k").unwrap(), "{file}"); + assert!(cache.is_writable(UnionSurface::TYPE, "unheld").unwrap(), "{file}"); + let error = cache.put("k", &constructed()).unwrap_err(); + assert_refused_with(&error, "k", &expected_mark(&detail)); + } +} + +#[test] +fn clean_and_short_payloads_are_writable_and_unmarked() { + let (value, found) = decode_payload_marked::(&vector("union-surface.msgpack")).unwrap(); + assert_eq!((value, found), (constructed(), None)); + // A payload short of its declared slots keeps its defaults and does not mark. + let (value, found) = decode_payload_marked::(&rmp_serde::to_vec(&("only",)).unwrap()).unwrap(); + assert_eq!((value.id.as_str(), value.note.as_str(), found), ("only", "", None)); +} + +#[test] +fn a_payload_past_the_nesting_limit_is_refused_never_marked() { + for file in ["depth-skipped-past-limit.msgpack", "depth-past-limit.msgpack"] { + let error = decode_payload_marked::(&vector(file)).unwrap_err(); + assert!(error.to_string().contains("the limit is 256"), "{file}: {error:#}"); + } +} + +#[test] +fn a_plain_serde_decode_marks_nothing_and_does_not_leak_a_scope() { + // No scope is open: the visitors note into nothing. + let value: UnionSurface = rmp_serde::from_slice(&vector("extra-slot-record.msgpack")).unwrap(); + assert_eq!(value, constructed()); + // A marked decode followed by a clean one: the first mark does not survive into the second. + assert!(decode_payload_marked::(&vector("extra-slot-record.msgpack")).unwrap().1.is_some()); + assert_eq!(decode_payload_marked::(&vector("union-surface.msgpack")).unwrap().1, None); +} + +#[test] +fn the_slot_store_rows_walk_through_the_cache() { + let rows: Vec = + rmp_serde::from_slice(&vector("slot-outcomes.msgpack")).unwrap(); + assert_eq!(rows.len(), 8); + let dir = tempfile::tempdir().unwrap(); + let mut walked = 0; + for (store, key, writable, _explained, type_name, slots, declared, occurrences, flatten_refused_by) in rows { + // The variant row is a record this runtime cannot hold: see the variant test. The consent store's `writable` and + // `explained` answer the compatible-id declaration, which this runtime has none of; its marks are walked below. + if flatten_refused_by.is_some() || store == "extra-slot-consent.cc" { + continue; + } + let case = dir.path().join(&key); + std::fs::create_dir_all(&case).unwrap(); + let (mut cache, _) = open_copy(&store, &case); + let type_id = if key == "unit-arm" { SlotUnit::TYPE } else { UnionSurface::TYPE }; + assert_eq!(cache.is_writable(type_id, &key).unwrap(), writable, "{store} {key}"); + let removal = if key == "unit-arm" { cache.delete::(&key) } else { cache.delete::(&key) }; + match writable { + true => assert!(removal.unwrap()), + false => { + let found = conflict(&removal.unwrap_err()).unread_slots.clone().unwrap(); + // The reference names the unit arm's class, Denied2; here the variant. + let expected_type = match type_name.as_deref().unwrap() { + "Denied2" => "Denied", + other => other, + }; + assert_eq!((found.type_name.as_str(), Some(found.slot_count), Some(found.declared_count), Some(found.occurrences)), (expected_type, slots, declared, occurrences)); + assert!(cache.snapshot_envelopes().iter().any(|held| held.key == key), "a refused removal removed {key}"); + } + } + walked += 1; + } + assert_eq!(walked, 3); +} + +#[test] +fn the_consent_store_marks_are_unconsented_here() { + let rows: Vec = + rmp_serde::from_slice(&vector("slot-outcomes.msgpack")).unwrap(); + let dir = tempfile::tempdir().unwrap(); + let mut found = 0; + for (store, key, _writable, _explained, _type_name, slots, declared, occurrences, _) in rows { + if store != "extra-slot-consent.cc" { + continue; + } + let (cache, _) = open_copy(&store, dir.path()); + let held = cache.snapshot_envelopes().into_iter().find(|held| held.key == key).unwrap(); + let (_, mark) = decode_payload_marked::(&held.payload).unwrap(); + match slots { + // The reference's `consented` row is writable there (a listed compatible id covers it); here every mark refuses. + None => assert_eq!(mark, None, "{key}"), + Some(slots) => { + let mark = mark.unwrap_or_else(|| panic!("{key} carried no mark")); + // The first type read is the document, whose reference name is SurfaceDeclaring. + assert_eq!(mark.type_name, "UnionSurface", "{key}"); + assert_eq!((Some(mark.slot_count), Some(mark.declared_count), Some(mark.occurrences)), (Some(slots), declared, occurrences), "{key}"); + assert!(!cache.is_writable(UnionSurface::TYPE, &key).unwrap(), "{key}"); + } + } + found += 1; + } + assert_eq!(found, 4); +} + +#[test] +fn every_write_entry_refuses_a_marked_key_before_any_store_is_touched() { + let dir = tempfile::tempdir().unwrap(); + let (mut cache, path) = open_copy("extra-slot-store.cc", dir.path()); + let expected = mark("Composition", 3, 2, 1); + let before = std::fs::read(&path).unwrap(); + let held = cache.snapshot_envelopes().into_iter().find(|held| held.key == "marked").unwrap(); + let clean = cache.snapshot_envelopes().into_iter().find(|held| held.key == "clean").unwrap(); + assert!(!cache.is_writable(UnionSurface::TYPE, "marked").unwrap()); + assert!(cache.is_writable(UnionSurface::TYPE, "clean").unwrap()); + + let replacing = envelope("marked", rmp_serde::to_vec(&constructed()).unwrap()); + assert_refused_with(&cache.put("marked", &constructed()).unwrap_err(), "marked", &expected); + assert_refused_with(&cache.put_envelope::(replacing.clone()).unwrap_err(), "marked", &expected); + assert_refused_with(&cache.put_raw_envelope(replacing.clone()).unwrap_err(), "marked", &expected); + assert_refused_with(&cache.put_prepared_batch(vec![replacing]).unwrap_err(), "marked", &expected); + assert_refused_with(&cache.delete::("marked").unwrap_err(), "marked", &expected); + assert_refused_with( + &cache.update::("marked", |current| current.unwrap()).unwrap_err(), + "marked", + &expected, + ); + + // A batch with a marked key refuses whole: its clean member is not written either. + let mut rewritten = clean.clone(); + rewritten.payload = rmp_serde::to_vec(&UnionSurface { version: 9, ..constructed() }).unwrap(); + let batch = vec![rewritten, envelope("marked", rmp_serde::to_vec(&constructed()).unwrap())]; + assert_refused_with(&cache.put_prepared_batch(batch).unwrap_err(), "marked", &expected); + + assert_eq!(std::fs::read(&path).unwrap(), before, "a refused write touched the store"); + assert_eq!(cache.snapshot_envelopes().into_iter().find(|entry| entry.key == "marked").unwrap(), held); + assert_eq!(cache.snapshot_envelopes().into_iter().find(|entry| entry.key == "clean").unwrap(), clean); + + // A write to another key succeeds, and the marked record's bytes, schema id and stored-at are carried unchanged. + cache.put("clean", &UnionSurface { version: 9, ..constructed() }).unwrap(); + cache.put("other", &constructed()).unwrap(); + assert!(cache.delete::("other").unwrap()); + let mut reopened = registered(); + reopened.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap(); + reopened.pull_all_backing_stores().unwrap(); + let carried = reopened.snapshot_envelopes().into_iter().find(|entry| entry.key == "marked").unwrap(); + assert_eq!(carried, held); + assert_eq!(carried.payload, vector("extra-slot-nested-record.msgpack")); + assert_eq!(reopened.get::("clean").unwrap().unwrap().version, 9); +} + +#[test] +fn a_unit_arm_with_slots_decodes_as_the_unit_arm_and_marks() { + let (document, found) = decode_payload_marked::(&unit_arm_payload()).unwrap(); + assert_eq!(document, SlotUnit { name: "unit-arm".into(), outcome: UnitOutcome::Denied }); + assert_eq!(found, Some(mark("Denied", 1, 0, 1))); + + let dir = tempfile::tempdir().unwrap(); + let (mut cache, _) = open_copy("extra-slot-store.cc", dir.path()); + assert_eq!(cache.get::("unit-arm").unwrap(), Some(document.clone())); + assert!(!cache.is_writable(SlotUnit::TYPE, "unit-arm").unwrap()); + let error = cache.put("unit-arm", &document).unwrap_err(); + assert_refused_with(&error, "unit-arm", &mark("Denied", 1, 0, 1)); + assert_refused_with(&cache.delete::("unit-arm").unwrap_err(), "unit-arm", &mark("Denied", 1, 0, 1)); + + // Several elements are still one marked object, and a clean unit arm is unmarked. + let (_, found) = decode_payload_marked::(&rmp_serde::to_vec(&(2u64, ("a", "b", "c"))).unwrap()).unwrap(); + assert_eq!(found, Some(mark("Denied", 3, 0, 1))); + let (_, found) = decode_payload_marked::(&vector("unit-arm-denied.msgpack")).unwrap(); + assert_eq!(found, None); +} + +fn unit_arm_payload() -> Vec { + let dir = tempfile::tempdir().unwrap(); + let (cache, _) = open_copy("extra-slot-store.cc", dir.path()); + cache.snapshot_envelopes().into_iter().find(|held| held.key == "unit-arm").unwrap().payload +} + +#[test] +fn the_variant_row_of_the_slot_store_is_refused_whole_by_the_store_reader() { + // `marked-variant` is a five-slot record (a variant): this runtime holds no variants, and its v1 snapshot reader + // refuses such a record, so the store as written cannot load here and the other rows are walked without it. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("extra-slot-store.cc"); + std::fs::write(&path, vector("extra-slot-store.cc")).unwrap(); + let mut cache = registered(); + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap(); + let error = cache.pull_all_backing_stores().unwrap_err(); + assert!(format!("{error:#}").contains("is not decodable"), "{error:#}"); +} + +fn slot_unit_envelope(key: &str, payload: Vec) -> CultCacheEnvelope { + CultCacheEnvelope { r#type: SlotUnit::TYPE.into(), ..envelope(key, payload) } +} + +#[test] +fn a_held_payload_the_type_cannot_read_is_read_only_and_refused_by_name() { + let mut cache = registered(); + // A newer writer's record: an arm this build lacks and an extra root slot. get cannot read it, and it is the data + // read-only exists to protect. + let newer = rmp_serde::to_vec(&("CANARY-name", (99u64, ("x",)), "CANARY-extra")).unwrap(); + cache.put_raw_envelope(slot_unit_envelope("n", newer)).unwrap(); + assert!(cache.get::("n").is_err()); + assert!(!cache.is_writable(SlotUnit::TYPE, "n").unwrap()); + let replacement = SlotUnit { name: "n".into(), outcome: UnitOutcome::Denied }; + let held = cache.snapshot_envelopes(); + for error in [ + cache.put("n", &replacement).unwrap_err(), + cache.put_raw_envelope(slot_unit_envelope("n", rmp_serde::to_vec(&replacement).unwrap())).unwrap_err(), + cache.delete::("n").unwrap_err(), + ] { + let message = format!("{error:#}"); + assert!(message.contains("\"n\"") && message.contains("tests.slot_unit") && message.contains("read-only"), "{message}"); + assert!(!message.contains("CANARY"), "{message}"); + } + assert_eq!(cache.snapshot_envelopes(), held); +} + +#[test] +fn a_corrupt_held_payload_is_refused_naming_the_key_never_its_bytes() { + let mut cache = registered(); + cache.put_raw_envelope(envelope("broken", rmp_serde::to_vec(&("CANARY-id", "CANARY-version")).unwrap())).unwrap(); + assert!(!cache.is_writable(UnionSurface::TYPE, "broken").unwrap()); + let error = cache.delete::("broken").unwrap_err(); + let message = format!("{error:#}"); + assert!(message.contains("\"broken\"") && message.contains("backup") && message.contains("store"), "{message}"); + assert!(!message.contains("build that wrote it"), "{message}"); + assert!(!message.contains("CANARY"), "{message}"); + assert!(cache.is_writable("tests.unregistered", "broken").is_err()); +} + +fn held_marked_and_clean() -> (CultCache, tempfile::TempDir, std::path::PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let (cache, path) = open_copy("extra-slot-store.cc", dir.path()); + (cache, dir, path) +} + +#[test] +fn load_soa_does_not_replace_or_drop_a_marked_record() { + use cultcache_rs::CultCacheSoaTable; + let (mut cache, _dir, path) = held_marked_and_clean(); + let all = cache.snapshot_envelopes(); + let held = all.iter().find(|held| held.key == "marked").unwrap().clone(); + let without: Vec<_> = all.iter().filter(|held| held.key != "marked").cloned().collect(); + let mut replaced = all.clone(); + replaced.iter_mut().find(|held| held.key == "marked").unwrap().payload = rmp_serde::to_vec(&constructed()).unwrap(); + for table in [without, replaced] { + let error = cache.load_soa(CultCacheSoaTable::from_envelopes(table)).unwrap_err(); + assert_refused_with(&error, "marked", &mark("Composition", 3, 2, 1)); + assert_eq!(cache.snapshot_envelopes(), all, "a refused load changed the image"); + } + // The same record carried unchanged, with another key added, loads. + let mut kept = all.clone(); + kept.push(envelope("added", rmp_serde::to_vec(&constructed()).unwrap())); + cache.load_soa(CultCacheSoaTable::from_envelopes(kept)).unwrap(); + assert_eq!(cache.snapshot_envelopes().iter().find(|held| held.key == "marked"), Some(&held)); + // A clean record may be dropped by a load. + let no_clean: Vec<_> = cache.snapshot_envelopes().into_iter().filter(|held| held.key != "clean").collect(); + cache.load_soa(CultCacheSoaTable::from_envelopes(no_clean)).unwrap(); + // The marked record is still on disk after the next batch. + let rewrite = envelope("added", rmp_serde::to_vec(&UnionSurface { version: 5, ..constructed() }).unwrap()); + cache.put_prepared_batch(vec![rewrite]).unwrap(); + let mut reopened = registered(); + reopened.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap(); + reopened.pull_all_backing_stores().unwrap(); + assert_eq!(reopened.snapshot_envelopes().iter().find(|held| held.key == "marked"), Some(&held)); +} + +#[test] +fn is_writable_agrees_with_the_writes_for_foreign_keys_and_other_types() { + let dir = tempfile::tempdir().unwrap(); + // Only UnionSurface registered: the store's unit-arm record is foreign here. + let path = dir.path().join("extra-slot-store.cc"); + std::fs::write(&path, without_variant_row(&vector("extra-slot-store.cc"))).unwrap(); + let mut cache = CultCache::new(); + cache.register_entry_type::().unwrap(); + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap(); + cache.pull_all_backing_stores().unwrap(); + assert!(cache.put("unit-arm", &constructed()).is_err()); + assert!(!cache.is_writable(UnionSurface::TYPE, "unit-arm").unwrap()); + assert!(cache.is_writable(UnionSurface::TYPE, "unheld").unwrap()); + + // Another registered type at a key a record of this one holds. + let (mut cache, _dir, _) = held_marked_and_clean(); + let other = SlotUnit { name: "clean".into(), outcome: UnitOutcome::Denied }; + assert!(cache.put("clean", &other).is_err()); + assert!(!cache.is_writable(SlotUnit::TYPE, "clean").unwrap()); + assert!(!cache.is_writable(SlotUnit::TYPE, "marked").unwrap()); + assert!(cache.is_writable(UnionSurface::TYPE, "clean").unwrap()); +} + +#[test] +fn a_marked_key_refuses_a_batch_wherever_it_sits() { + let (mut cache, _dir, path) = held_marked_and_clean(); + let before = std::fs::read(&path).unwrap(); + let marked = envelope("marked", rmp_serde::to_vec(&constructed()).unwrap()); + let clean = |key: &str| envelope(key, rmp_serde::to_vec(&constructed()).unwrap()); + for batch in [ + vec![marked.clone(), clean("a"), clean("b")], + vec![clean("a"), marked.clone(), clean("b")], + vec![clean("a"), clean("b"), marked.clone()], + ] { + let error = cache.put_prepared_batch(batch).unwrap_err(); + assert_refused_with(&error, "marked", &mark("Composition", 3, 2, 1)); + assert_eq!(std::fs::read(&path).unwrap(), before); + assert!(cache.snapshot_envelopes().iter().all(|held| held.key != "a" && held.key != "b")); + } +} + +#[test] +fn a_marked_decode_leaves_no_scope_open() { + assert_eq!(cultcache_rs::__enter_slots(), 0); + decode_payload_marked::(&vector("extra-slot-record.msgpack")).unwrap(); + assert_eq!(cultcache_rs::__enter_slots(), 0, "a marked decode left its scope open on this thread"); + let _ = decode_payload_marked::(&vector("depth-skipped-past-limit.msgpack")); + assert_eq!(cultcache_rs::__enter_slots(), 0); +} + +#[test] +fn is_writable_is_false_for_a_type_with_no_home_store() { + let dir = tempfile::tempdir().unwrap(); + let mut cache = registered(); + // One store, routed to SlotUnit only: UnionSurface has no home. + cache.add_backing_store(SingleFileMessagePackBackingStore::new(dir.path().join("s.cc")), [SlotUnit::TYPE]).unwrap(); + assert!(cache.put("k", &constructed()).is_err()); + assert!(!cache.is_writable(UnionSurface::TYPE, "k").unwrap()); + assert!(cache.is_writable(SlotUnit::TYPE, "k").unwrap()); +} + +#[test] +fn load_soa_refuses_a_same_length_replacement_of_a_marked_record() { + use cultcache_rs::CultCacheSoaTable; + let (mut cache, _dir, _) = held_marked_and_clean(); + let all = cache.snapshot_envelopes(); + let marked = all.iter().find(|held| held.key == "marked").unwrap(); + // The clean record's bytes padded to the marked record's length: same length, different bytes. + let mut same_length = vec![0xc0; marked.payload.len()]; + same_length[..2].copy_from_slice(&[0x91, 0xa1]); + assert_eq!(same_length.len(), marked.payload.len()); + let mut replaced = all.clone(); + replaced.iter_mut().find(|held| held.key == "marked").unwrap().payload = same_length; + let error = cache.load_soa(CultCacheSoaTable::from_envelopes(replaced)).unwrap_err(); + assert_refused_with(&error, "marked", &mark("Composition", 3, 2, 1)); + assert_eq!(cache.snapshot_envelopes(), all); +} diff --git a/packages/cultcache-rs/tests/union_parity.rs b/packages/cultcache-rs/tests/union_parity.rs index 24fe999a..c51bd3d8 100644 --- a/packages/cultcache-rs/tests/union_parity.rs +++ b/packages/cultcache-rs/tests/union_parity.rs @@ -1,244 +1,17 @@ //! The Rust runtime held to tests/vectors/union-parity, written by the C# reference. //! Bytes and outcomes are read from the committed files; only the constructed value is spelled here. -use std::path::PathBuf; - use cultcache_rs::CultCache; use cultcache_rs::CacheBackingStore; use cultcache_rs::CultCacheEnvelope; use cultcache_rs::MAX_PAYLOAD_DEPTH; use cultcache_rs::PayloadTooDeep; use cultcache_rs::check_payload_depth; -use cultcache_rs::CultSlots; -use cultcache_rs::CultUnion; use cultcache_rs::DatabaseEntry; use cultcache_rs::SingleFileMessagePackBackingStore; -#[derive(Clone, Debug, PartialEq, CultUnion)] -enum Node { - #[cultcache(key = 0)] - Box(BoxArm), - #[cultcache(key = 1)] - Text(TextArm), - #[cultcache(key = 2)] - Use(UseArm), - #[cultcache(key = 5)] - Image(ImageArm), - #[cultcache(key = 6)] - Wrap(WrapArm), -} - -#[derive(Clone, Debug, PartialEq, CultSlots)] -struct WrapArm { - #[cultcache(key = 0)] - inner: Option>, - #[cultcache(key = 1)] - id: String, -} - -#[derive(Clone, Debug, PartialEq, CultSlots)] -struct BoxArm { - #[cultcache(key = 0)] - id: String, - #[cultcache(key = 1)] - children: Vec, -} - -#[derive(Clone, Debug, PartialEq, CultSlots)] -struct TextArm { - #[cultcache(key = 0)] - id: String, - #[cultcache(key = 1)] - content: String, -} - -#[derive(Clone, Debug, PartialEq, CultSlots)] -struct UseArm { - #[cultcache(key = 0)] - id: String, - #[cultcache(key = 1)] - composition: Composition, - #[cultcache(key = 2)] - slots: Vec>, -} - -// Slot 2 is retired: a gap written nil. -#[derive(Clone, Debug, PartialEq, CultSlots)] -struct ImageArm { - #[cultcache(key = 0)] - id: String, - #[cultcache(key = 1)] - uri: String, - #[cultcache(key = 3)] - alt: String, -} - -#[derive(Clone, Debug, PartialEq, CultSlots)] -struct Composition { - #[cultcache(key = 0)] - name: String, - #[cultcache(key = 1)] - body: Option>, -} - -#[derive(Clone, Debug, PartialEq, CultUnion)] -enum Outcome { - #[cultcache(key = 0)] - Pending(PendingArm), - #[cultcache(key = 1)] - Completed(CompletedArm), - #[cultcache(key = 2)] - Denied(IdArm), - #[cultcache(key = -2147483648)] - Floor(IdArm), - #[cultcache(key = 2147483647)] - Ceiling(IdArm), -} - -// The codec's unit arm (no members, written [key, []]), kept apart from the golden: a document list's arms carry ids. -#[derive(Clone, Debug, PartialEq, CultUnion)] -enum UnitOutcome { - #[cultcache(key = 2)] - Denied, - #[cultcache(key = -2147483648)] - Floor, - #[cultcache(key = 2147483647)] - Ceiling, -} - -#[derive(Clone, Debug, PartialEq, CultSlots)] -struct PendingArm { - #[cultcache(key = 0)] - message: String, - #[cultcache(key = 1)] - id: String, -} - -// An arm with no members of its own: just its element id. -#[derive(Clone, Debug, PartialEq, CultSlots)] -struct IdArm { - #[cultcache(key = 0)] - id: String, -} - -#[derive(Clone, Debug, PartialEq, CultSlots)] -struct CompletedArm { - #[cultcache(key = 0)] - source_version: i32, - #[cultcache(key = 1)] - id: String, -} - -#[derive(Clone, Debug, PartialEq, CultSlots)] -struct NamedArm { - #[cultcache(key = 0)] - name: String, -} - -#[derive(Clone, Debug, PartialEq, CultSlots)] -struct UsesArm { - #[cultcache(key = 0)] - name: String, - #[cultcache(key = 1)] - uses: i32, -} - -#[derive(Clone, Debug, PartialEq, CultSlots)] -struct CountArm { - #[cultcache(key = 0)] - name: String, - #[cultcache(key = 1)] - count: i32, -} - -#[derive(Clone, Debug, PartialEq, CultUnion)] -enum Item { - #[cultcache(key = 0)] - Gem(NamedArm), - #[cultcache(key = 1)] - Tool(UsesArm), - #[cultcache(key = 2)] - Part(CountArm), -} - -// The same arms as Item under its own keys: key 2 is unknown to it. -#[derive(Clone, Debug, PartialEq, CultUnion)] -enum CraftedItem { - #[cultcache(key = 0)] - Tool(UsesArm), - #[cultcache(key = 1)] - Part(CountArm), -} - -#[derive(Clone, Debug, PartialEq, DatabaseEntry)] -#[cultcache(type = "tests.union_surface", schema = "tests.union_surface.v1")] -struct UnionSurface { - #[cultcache(key = 0)] - id: String, - #[cultcache(key = 1)] - version: i32, - #[cultcache(key = 2)] - root: Option, - #[cultcache(key = 3)] - outcomes: Vec, - #[cultcache(key = 4)] - part: Option, - #[cultcache(key = 5)] - crafted: Option, - #[cultcache(key = 6)] - missing: Option, -} - -fn text(id: &str, content: &str) -> Node { - Node::Text(TextArm { id: id.into(), content: content.into() }) -} - -fn image(id: &str, uri: &str, alt: &str) -> Node { - Node::Image(ImageArm { id: id.into(), uri: uri.into(), alt: alt.into() }) -} - -fn boxed(id: &str, children: Vec) -> Node { - Node::Box(BoxArm { id: id.into(), children }) -} - -fn constructed() -> UnionSurface { - let inner = boxed("0000000000d0", vec![text("0000000000d1", "x"), image("0000000000d2", "uri://a", "alt")]); - UnionSurface { - id: "surface".into(), - version: 3, - root: Some(boxed( - "0000000000c0", - vec![ - text("0000000000c1", "hello"), - Node::Use(UseArm { - id: "0000000000c2".into(), - composition: Composition { name: "comp".into(), body: Some(std::boxed::Box::new(inner)) }, - slots: vec![Some(text("0000000000e1", "slot")), None, Some(boxed("0000000000e2", vec![]))], - }), - image("0000000000c3", "uri://b", ""), - ], - )), - outcomes: vec![ - Outcome::Pending(PendingArm { message: "wait".into(), id: "0000000000a1".into() }), - Outcome::Completed(CompletedArm { source_version: 7, id: "0000000000a2".into() }), - Outcome::Denied(id_arm("0000000000a3")), - ], - part: Some(Item::Tool(UsesArm { name: "hammer".into(), uses: 12 })), - crafted: Some(CraftedItem::Part(CountArm { name: "gear".into(), count: 4 })), - missing: None, - } -} - -fn id_arm(id: &str) -> IdArm { - IdArm { id: id.into() } -} - -fn vector(name: &str) -> Vec { - let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../tests/vectors/union-parity") - .join(name); - std::fs::read(&path).unwrap_or_else(|error| panic!("{}: {error}", path.display())) -} +mod common; +use common::*; fn decode(bytes: &[u8]) -> Result { rmp_serde::from_slice(bytes).map_err(|error| error.to_string()) @@ -325,11 +98,10 @@ fn an_unknown_arm_lists_the_declared_keys_and_never_echoes_the_payload() { #[test] fn a_union_that_is_not_a_key_and_slots_pair_is_refused_naming_the_enum() { - let cases: [(&str, Vec); 4] = [ + let cases: [(&str, Vec); 3] = [ ("an empty array", vec![0x90]), ("a trailing element", rmp_serde::to_vec(&(1u64, (7u8,), 0u8)).unwrap()), ("a string key", rmp_serde::to_vec(&("CANARY-key", (7u8,))).unwrap()), - ("a unit arm with slots", rmp_serde::to_vec(&(2u64, ("CANARY-slot",))).unwrap()), ]; for (label, bytes) in cases { let message = rmp_serde::from_slice::(&bytes).unwrap_err().to_string(); diff --git a/packages/cultmesh-rs/src/lib.rs b/packages/cultmesh-rs/src/lib.rs index 33cb4858..18a02fcb 100644 --- a/packages/cultmesh-rs/src/lib.rs +++ b/packages/cultmesh-rs/src/lib.rs @@ -732,6 +732,18 @@ impl CultMeshNode { let cultnet_rs::CultNetMessage::SnapshotResponseRaw { documents, .. } = response else { anyhow::bail!("expected cultnet.snapshot_response_raw.v0"); }; + self.apply_raw_snapshot_documents(documents) + } + + /// Applies each snapshot document of a registered schema with `put_raw_envelope`. The first refusal ends the pull + /// and the documents before it stay applied; a key this node holds read-only (unread slots from a newer peer, or a + /// payload this build cannot read) is refused like any other. C# applies a raw snapshot as one cache commit, so a + /// refusal there applies nothing: this is a divergence, owed by follow-up mesh-snapshot-atomic (stage the pull through + /// `put_prepared_batch`). + fn apply_raw_snapshot_documents( + &mut self, + documents: Vec, + ) -> Result { let mut applied = 0usize; for document in documents { let Some(document_type) = @@ -1199,6 +1211,47 @@ mod tests { Note => "cultmesh.test.note.v0", }); + fn raw_note(key: &str, payload: Vec) -> cultnet_rs::CultNetRawDocumentRecord { + cultnet_rs::CultNetRawDocumentRecord { + schema_id: "cultmesh.test.note.v0".to_string(), + record_key: key.to_string(), + stored_at: "2026-01-01T00:00:00Z".to_string(), + payload_encoding: cultnet_rs::CultNetRawPayloadEncoding::Messagepack, + payload, + source_runtime_id: None, + source_agent_id: None, + source_role: None, + tags: None, + } + } + + #[test] + fn a_snapshot_document_for_a_read_only_key_ends_the_pull_unlike_csharp_which_applies_nothing() -> Result<()> { + let temp = tempfile::tempdir()?; + let mut node = CultMesh::create_node(temp.path().join("m.cc"), TestDocuments, Default::default())?; + // A newer peer's note: one slot past the declared one. + let newer = rmp_serde::to_vec(&("newer body", "extra slot"))?; + let plain = |body: &str| rmp_serde::to_vec(&(body,)); + assert_eq!(node.apply_raw_snapshot_documents(vec![raw_note("held", newer)])?, 1); + assert_eq!(node.get_required::("held")?.body, "newer body"); + + let error = node + .apply_raw_snapshot_documents(vec![ + raw_note("before", plain("b")?), + raw_note("held", plain("overwrite")?), + raw_note("after", plain("a")?), + ]) + .unwrap_err(); + let conflict = error.downcast_ref::().expect("typed refusal"); + assert_eq!(conflict.record_key, "held"); + assert!(conflict.unread_slots.is_some()); + assert!(!error.to_string().contains("extra slot")); + assert_eq!(node.get_required::("before")?.body, "b"); + assert_eq!(node.get_required::("held")?.body, "newer body"); + assert!(node.get::("after")?.is_none()); + Ok(()) + } + #[test] fn node_round_trips_registered_documents() -> Result<()> { let temp = tempfile::tempdir()?; diff --git a/packages/cultnet-rs/src/replication.rs b/packages/cultnet-rs/src/replication.rs index 743a28fd..7816e7dd 100644 --- a/packages/cultnet-rs/src/replication.rs +++ b/packages/cultnet-rs/src/replication.rs @@ -146,6 +146,7 @@ impl CultNetDocumentRegistry { schema_id: binding.schema_id.clone(), schema_names: vec![holder.document_type.clone(), binding.document_type.clone()], record_key: String::new(), + unread_slots: None, })); } self.bindings_by_schema_id From 676f611c1fe6c057b59e718e592e201aca2b4906 Mon Sep 17 00:00:00 2001 From: Libby Date: Thu, 12 Nov 2026 05:03:29 +0000 Subject: [PATCH 48/93] TypeScript: a record with unread slots is held read-only (slot-readonly-ts) (#128) --- packages/cultcache-ts/CHANGELOG.md | 1 + packages/cultcache-ts/README.md | 7 +- packages/cultcache-ts/src/browser.ts | 1 + packages/cultcache-ts/src/codec.ts | 70 ++- packages/cultcache-ts/src/cult-cache.ts | 179 +++++-- .../cultcache-ts/src/document-formatter.ts | 9 +- packages/cultcache-ts/src/document.ts | 17 +- packages/cultcache-ts/src/store-format.ts | 7 +- packages/cultcache-ts/src/types.ts | 20 + .../cultcache-ts/test/slot-readonly.test.ts | 461 ++++++++++++++++++ .../cultcache-ts/test/union-parity.test.ts | 202 +------- .../test/union-surface-fixture.ts | 240 +++++++++ 12 files changed, 976 insertions(+), 238 deletions(-) create mode 100644 packages/cultcache-ts/test/slot-readonly.test.ts create mode 100644 packages/cultcache-ts/test/union-surface-fixture.ts diff --git a/packages/cultcache-ts/CHANGELOG.md b/packages/cultcache-ts/CHANGELOG.md index e71821c8..b91a0535 100644 --- a/packages/cultcache-ts/CHANGELOG.md +++ b/packages/cultcache-ts/CHANGELOG.md @@ -20,6 +20,7 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following - A store whose canonical-schema catalog slot is not a string is refused as `StoreUnreadableError`. - The cache holds one record per key (variants:ruling:cache-refuses-retype). A `put` or `putEnvelope` that lands a second type under a key the cache holds, or under a key a store carries foreign, is refused with `SchemaConflictError` naming the key, even with no store (a memory-only cache used to hold both types at the key). A type that owns the held record's schema id (as for the store) replaces it, unless the held record is in a store other than the written type's home: that rename is refused with `SchemaConflictError` (remove the record first). Two stores holding one key, registered or foreign, refuse the load and admit nothing. A store's `delete` removes the record whose stored schema id the removal's type owns, whatever the arrived record's `type` string. - A store holding a record no registered type claims now loads (it used to throw "No schema is registered..."): the cache does not serve the record, lists it in `foreignRecords()`, keeps its bytes through every write, and refuses a `put` or a `delete` at its key with `SchemaConflictError` (variants:ruling:unheld-removal-refused-at-commit). Registering a type that claims its schema id serves it at the next `pullAllBackingStores`. +- A record whose payload carries a slot past its reader's declared slots, at any depth (the record, a nested record, a list element, a union arm, a unit arm), is now held read-only: it loads and reads, and every `put`, `putEnvelope`, `update` or `delete` of its key is refused with `SchemaConflictError` before a store is touched (cultlib-gaps:ruling:extra-trailing-slots, as the C# reference). A load used to skip the extra slots silently and a later write shed them. The refusal names the schema, the key, the type that was read with extra slots and both slot counts, never a slot value, and carries them as `SchemaConflictError.unreadSlots`. An untouched record keeps its bytes through writes of other keys. Missing trailing slots keep their declared defaults and mark nothing. A `put` never marks (the value is encoded by this runtime); a `putEnvelope` of a payload carrying extra slots lands its bytes and holds the record read-only. A mark at depth 0 is consented to only when the record arrived under a schema id other than the reader's own that the reader lists compatible, and that id's catalog entry lists those slots (a writer that adds a slot and keeps the reader's id is never consented to) (`unreadSlots.explained`); every other mark refuses whatever is declared. New: `CultCache.isWritable(definition, key)` (live, advisory), `CultCache.unreadSlots(definition, key)` and the exported `CultCacheUnreadSlots`. Documents with no slot codec (a plain map, a user formatter) never mark. A `delete` is by key: a type that owns the schema id of the record held at the key as another type removes that one record (one store call, the cache dropped once the store has removed it), and a type that does not own it is refused with `SchemaConflictError`, as a `put` is; `isWritable` answers both. Implied semver: a minor bump (0.16.0) while the package is 0.x; a major one at 1.0 and after. ### Added diff --git a/packages/cultcache-ts/README.md b/packages/cultcache-ts/README.md index 2096d258..6fea306a 100644 --- a/packages/cultcache-ts/README.md +++ b/packages/cultcache-ts/README.md @@ -371,8 +371,11 @@ const sceneDocument = defineDocumentType({ }); ``` -- A slot is `[name, codec, default?]`. A gap is written nil and skipped on read; an extra trailing slot is skipped; a - slot missing from the payload (or nil, when a default is declared) takes its default, and is refused without one. +- A slot is `[name, codec, default?]`. A gap is written nil and skipped on read; an extra trailing slot is read past and + marks the record read-only (every write or removal of its key is refused with `SchemaConflictError`, `cache.isWritable` + asks first). The one writable case is a depth-0 extra slot of a record that arrived under a schema id other than the reader's own, which the + reader lists in `compatibleSchemaIds`, whose catalog entry lists those slots: declaring that id consents to the loss, and a write restamps + the record. A nested slot, or a writer that keeps the reader's own id, is never consented to. A slot missing from the payload (or nil, when a default is declared) takes its default, and is refused without one. - A union refuses an undeclared arm key on read, naming the union, the key and the declared keys. Nothing falls back to a default arm. An arm with no slots is `codecs.unit` and is written `[key, []]`. - A refusal names the union, the field and the slot; it never echoes the value it refused. diff --git a/packages/cultcache-ts/src/browser.ts b/packages/cultcache-ts/src/browser.ts index 668fc0b8..98995acb 100644 --- a/packages/cultcache-ts/src/browser.ts +++ b/packages/cultcache-ts/src/browser.ts @@ -34,6 +34,7 @@ export type { CultCacheIndexScalar, InferCultCacheSchemaValue, CultCacheStoreRegistration, + CultCacheUnreadSlots, PushAllOptions, } from "./types"; export type { diff --git a/packages/cultcache-ts/src/codec.ts b/packages/cultcache-ts/src/codec.ts index 9b885159..a2dd7b15 100644 --- a/packages/cultcache-ts/src/codec.ts +++ b/packages/cultcache-ts/src/codec.ts @@ -198,6 +198,66 @@ type SlotSpec = type SlotsSpec = Readonly }[keyof TValue & string]>>; +// The slots a payload decode read past, noted by `slots` and the unit arm while the one decode entry (decodeNoting) has a scope +// open, and read out by that entry. Module-scoped as the reference's is thread-bound: a decode is synchronous, and a decode +// outside the entry (a codec called directly, a CultMesh read) notes nothing. No codec signature carries it. +export interface NotedSlots { + /** The first type read with extra slots, outermost first. */ + readonly typeName: string; + readonly slotCount: number; + readonly declaredCount: number; + readonly occurrences: number; + /** True when every noted object was the document's own: a union adds no depth, each record or arm inside a field adds one. */ + readonly depth0Only: boolean; +} + +interface SlotScope { + depth: number; + found?: { typeName: string; slotCount: number; declaredCount: number; occurrences: number; depth0Only: boolean }; +} + +let scope: SlotScope | undefined; + +/** The one decode entry: runs the decode with a scope open and returns its value with what it noted. */ +export function decodeNoting(decode: () => T): { value: T; unreadSlots?: NotedSlots } { + const outer = scope; + const mine: SlotScope = { depth: 0 }; + scope = mine; + try { + const value = decode(); + return mine.found ? { value, unreadSlots: mine.found } : { value }; + } finally { + scope = outer; + } +} + +function noteExtraSlots(typeName: string, slotCount: number, declaredCount: number): void { + if (!scope) return; + const found = scope.found; + if (found) { + found.occurrences += 1; + found.depth0Only &&= scope.depth === 0; + } else { + scope.found = { typeName, slotCount, declaredCount, occurrences: 1, depth0Only: scope.depth === 0 }; + } +} + +// What a record inside a field reads at: one deeper than the record holding the field. +function nested(read: () => T): T { + if (!scope) return read(); + scope.depth += 1; + try { + return read(); + } finally { + scope.depth -= 1; + } +} + +// The reference's type name for a mark: the type, not its namespace or the type it is nested in. +function simpleTypeName(typeName: string | undefined): string { + return typeName === undefined ? "record" : typeName.slice(Math.max(typeName.lastIndexOf("."), typeName.lastIndexOf("+")) + 1); +} + const INT32_MIN = -(2 ** 31); const INT32_MAX = 2 ** 31 - 1; @@ -232,7 +292,7 @@ function pascalCase(name: string): string { } // A positional record: slot `key` holds field `name`. Gaps are written nil and skipped on read, extra trailing slots -// are skipped, and a slot missing from the payload takes its declared default or is refused. A default fills only a +// are read past and noted (decodeNoting): the record that carried them is read-only, never rewritten without them, and a slot missing from the payload takes its declared default or is refused. A default fills only a // missing trailing slot, never a nil: as in the reference, a nil in a reference slot (string, bytes, record, union, // list) reads as null and writes back as nil, so a record the reference wrote re-encodes byte-identical, and a nil in // a value slot (int, float, bool) is refused unless the slot is nullable. @@ -281,6 +341,9 @@ export function slots(spec: SlotsSpec, typeName?: decode(raw) { expect(raw, "an array of slots", Array.isArray(raw)); const input = raw as unknown[]; + if (input.length > length) { + noteExtraSlots(simpleTypeName(typeName), input.length, length); + } const out: Record = {}; for (const field of fields) { const present = field.key < input.length; @@ -291,7 +354,7 @@ export function slots(spec: SlotsSpec, typeName?: ? defaultOf(field) : input[field.key] === null && !field.codec.valueType ? null - : at(`${field.name} (slot ${field.key})`, () => field.codec.decode(input[field.key])); + : at(`${field.name} (slot ${field.key})`, () => nested(() => field.codec.decode(input[field.key]))); } return out as TValue; }, @@ -363,6 +426,9 @@ export function union( if (arm === undefined) { throw new Error(`Union ${name}: unknown arm key ${key} (declared keys: ${declared}).`); } + if (arm.body === unit && raw[1].length > 0) { + noteExtraSlots(arm.armName, raw[1].length, 0); + } const fields = arm.body === unit ? {} : at(`arm ${arm.armName}`, () => (arm.body as SlotsCodec).decode(raw[1])); return { kind: arm.armName, ...fields } as unknown as TValue; }, diff --git a/packages/cultcache-ts/src/cult-cache.ts b/packages/cultcache-ts/src/cult-cache.ts index 443c0c18..6cf7a8cb 100644 --- a/packages/cultcache-ts/src/cult-cache.ts +++ b/packages/cultcache-ts/src/cult-cache.ts @@ -1,6 +1,6 @@ import { declaresRole, - decodeDocumentPayload, + decodeDocumentPayloadMarked, documentPayloadBytes, documentPayloadFromBytes, encodeDocumentPayload, @@ -8,6 +8,7 @@ import { schemaIdentityOf, } from "./document"; import { resolveDocumentFormatter } from "./document-formatter"; +import type { NotedSlots } from "./codec"; import type { DocumentPayload } from "./document"; import { SchemaConflictError, schemaIdForEnvelope, writtenOwns } from "./store-format"; import type { @@ -20,6 +21,7 @@ import type { CultCacheEnvelope, CultCacheSchemaCatalogEntry, CultCacheStoreRegistration, + CultCacheUnreadSlots, } from "./types"; type Accessor = (value: unknown) => string | undefined; @@ -27,7 +29,7 @@ type Accessor = (value: unknown) => string | undefined; // A registered definition reads and writes its documents through the owner, on payloads the cache has cloned. type RegisteredFormatter = { encode(value: unknown): DocumentPayload; - decode(payload: DocumentPayload): unknown; + decode(payload: DocumentPayload): { value: unknown; unreadSlots?: NotedSlots }; }; type RegisteredDefinition = { @@ -50,6 +52,10 @@ type HydratedEntry = CultCacheEnvelope & { catalogEntry: NonNullable; value: unknown; lookups: EntryLookups; + // What the payload decode read past: set when the record was admitted, and it makes the record read-only unless explained. + unreadSlots?: CultCacheUnreadSlots; + // The schema id the record arrived under, before it was admitted under its reader's: what an unread-slots refusal names. + storedSchemaId?: string; }; // Everything a pull replaces. Built whole, then swapped in with one assignment. @@ -84,6 +90,20 @@ type DerivedTypeLookups = { readonly indexMaps: Map>; }; +// The consent a compatible-id declaration gives (cultlib-gaps:ruling:slot-mark-consent): a mark is explained only when the record +// arrived under an id other than the reader's own that the reader lists compatible, it is at +// depth 0 and the schema comparison of the catalog entry the record arrived with against the reader's own reported the slots it +// carries as ignored, so its slot count is at most the highest ignored slot plus one. A record written or put through the cache +// arrives under the reader's own entry, which ignores nothing, so no mark of it is explained. +function explainedBy(noted: NotedSlots, reader: CultCacheSchemaCatalogEntry, arrivedId: string, arrived: CultCacheSchemaCatalogEntry | undefined): boolean { + if (arrivedId === reader.schemaId || !(reader.compatibleSchemaIds ?? []).includes(arrivedId)) { + return false; + } + const declared = new Set((reader.members ?? []).map((member) => member.slot)); + const ignored = (arrived?.members ?? []).map((member) => member.slot).filter((slot) => !declared.has(slot)); + return noted.depth0Only && noted.slotCount <= Math.max(-1, ...ignored) + 1; +} + function emptyHydratedState(): HydratedState { return { entries: new Map(), @@ -312,7 +332,7 @@ export class CultCache { } const payload = this.#cloneBytes(entry.payload); - const value = registered.formatter.decode(payload); + const { value, unreadSlots } = registered.formatter.decode(payload); let admitted: CultCacheEnvelope = { ...entry, type, payload }; if (registered.global) { const persistedKey = persistedGlobalKeys.get(type); @@ -327,7 +347,7 @@ export class CultCache { admitted = { ...admitted, key: CultCache.GLOBAL_KEY }; } } - const hydrated = this.#admitEntry(next, registered, admitted, value); + const hydrated = this.#admitEntry(next, registered, admitted, value, unreadSlots); const other = this.#heldUnderOtherType(next, type, hydrated.key); if (other !== undefined) { throw new Error(`Record ${hydrated.key} is held by two stores, as type "${other.type}" and as type "${type}".`); @@ -518,10 +538,9 @@ export class CultCache { if (!envelope.storedAt || envelope.storedAt.trim().length === 0) { throw new Error(`CultCache envelope storedAt for type "${definition.type}" must be non-empty.`); } - this.#requireGlobalKey(registered, envelope.key); const payload = this.#cloneBytes(envelope.payload); - const parsed = registered.formatter.decode(payload) as CultCacheDocumentValue; + const { value: parsed, unreadSlots } = registered.formatter.decode(payload) as { value: CultCacheDocumentValue; unreadSlots?: NotedSlots }; const entry: CultCacheEnvelope = { key: envelope.key, type: envelope.type, @@ -532,7 +551,7 @@ export class CultCache { schemaId: registered.catalogEntry.schemaId, catalogEntry: registered.catalogEntry, }; - await this.#writeNow(registered, entry, parsed); + await this.#writeNow(registered, entry, parsed, unreadSlots); return parsed; }); } @@ -585,6 +604,29 @@ export class CultCache { }); } + /** What the record's payload carried past this build's declared slots, or undefined: the held record is read-only unless `explained`. */ + unreadSlots(definition: AnyCultCacheDocumentDefinition, key: string): CultCacheUnreadSlots | undefined { + return this.#hydrated.entries.get(this.#entryId(this.#requireDefinition(definition).definition.type, key))?.unreadSlots; + } + + /** + * Whether a write of the record's own type at the key would be let through: it asks the judge #writeNow asks, of the record the + * cache holds (a key a store carries foreign, a record of another type the write does not own or cannot replace, a global at + * another key, a record carrying unread slots nothing consents to). It answers the WRITE form. A removal asks the judge's + * removal form and differs in two states: an owner's `delete` of a record held in another store than its own home succeeds + * where this is false (a write would push to the wrong store), and `delete` of a global type at a key other than __global__ is + * the plain false of an unheld key where this is false. Live and advisory: the write or removal still decides. + */ + isWritable(definition: AnyCultCacheDocumentDefinition, key: string): boolean { + const registered = this.#requireDefinition(definition); + try { + this.#judge(registered, key, this.#probe(registered, key)); + return true; + } catch { + return false; + } + } + // What the last pull found that no registered type claims, by key ordinal. A later registration claims a record at the next pull. foreignRecords(): readonly CultCacheForeignRecord[] { return [...this.#hydrated.foreign].sort((left, right) => compareOrdinal(left.key, right.key)); @@ -680,9 +722,8 @@ export class CultCache { value: CultCacheDocumentValue, ): Promise> { const registered = this.#requireDefinition(definition); - this.#requireGlobalKey(registered, key); const payload = registered.formatter.encode(value); - const parsed = registered.formatter.decode(payload) as CultCacheDocumentValue; + const { value: parsed, unreadSlots } = registered.formatter.decode(payload) as { value: CultCacheDocumentValue; unreadSlots?: NotedSlots }; const entry: CultCacheEnvelope = { key, type: definition.type, @@ -691,26 +732,16 @@ export class CultCache { schemaId: registered.catalogEntry.schemaId, catalogEntry: registered.catalogEntry, }; - await this.#writeNow(registered, entry, parsed); + await this.#writeNow(registered, entry, parsed, unreadSlots); return parsed; } // Validates the whole write (home, global singleton, name and index accessors) before the store // is touched; once the store accepts, applying to the cache has no fallible step. - async #writeNow(registered: RegisteredDefinition, entry: CultCacheEnvelope, value: unknown): Promise { + async #writeNow(registered: RegisteredDefinition, entry: CultCacheEnvelope, value: unknown, unreadSlots?: NotedSlots): Promise { const home = this.#homeStore(entry.type); - const retyped = this.#refuseRetype(entry); - if (retyped !== undefined && this.#homeStore(retyped.type) !== home) { - // The write owns the held record's id and would replace it, but the held record is in another store; the push - // reaches only this type's home, so the old record would stay in its own file and load as a second holder. - throw new SchemaConflictError( - schemaIdForEnvelope(entry), - [retyped.type, entry.type], - entry.key, - `CultCache holds record "${entry.key}" as type "${retyped.type}" in another store than the home of type "${entry.type}", so the rename cannot replace it; remove the record first.`, - ); - } - const hydrated = this.#admitEntry(this.#hydrated, registered, entry, value); + const retyped = this.#judge(registered, entry.key, entry); + const hydrated = this.#admitEntry(this.#hydrated, registered, entry, value, unreadSlots); this.#refuseIndexSharing(this.#hydrated, entry.type, registered.catalogEntry.schemaName, entry.key, hydrated.lookups); const legacy = this.#hydrated.legacyGlobals.get(entry.type); await home?.push(entry); @@ -725,6 +756,85 @@ export class CultCache { this.#installEntry(this.#hydrated, registered, hydrated); } + // An envelope of the definition's type at the key, carrying nothing: what the judge asks about a write or a removal by that type. + #probe(registered: RegisteredDefinition, key: string): CultCacheEnvelope { + return { + key, type: registered.definition.type, payload: documentPayloadFromBytes(new Uint8Array(0)), storedAt: "", + schemaId: registered.catalogEntry.schemaId, catalogEntry: registered.catalogEntry, + }; + } + + // The one judge of whether a key may be written (a written envelope) or removed (none), before any store is touched: it throws the + // refusal, and #writeNow, #deleteNow and isWritable all ask it, so the query cannot disagree with the write. For a write it + // returns the held record of another type that the write owns and so restamps, if any. A refusal names keys and schemas, + // never a payload or a slot value. + #judge(registered: RegisteredDefinition, key: string, written?: CultCacheEnvelope): HydratedEntry | undefined { + const type = registered.definition.type; + const held = this.#hydrated.entries.get(this.#entryId(type, key)); + if (written === undefined) { + // A removal is by key: the record it removes is the one held at the key, of this type or of another type whose schema id + // this type owns (the record a write of this type would replace). A held record of a type it does not own is refused. + this.#refuseForeignKey(key, type, "removed"); + this.#refuseUnread(held); + if (held !== undefined) { + return undefined; + } + const other = this.#heldUnderOtherType(this.#hydrated, type, key); + if (other === undefined) { + return undefined; + } + if (!writtenOwns(this.#probe(registered, key), schemaIdForEnvelope(other))) { + throw new SchemaConflictError( + registered.catalogEntry.schemaId, + [other.type, type], + key, + `CultCache holds record "${key}" as type "${other.type}", so type "${type}" cannot remove it.`, + ); + } + this.#refuseUnread(other); + return other; + } + this.#requireGlobalKey(registered, key); + const retyped = this.#refuseRetype(written); + this.#refuseUnread(held); + this.#refuseUnread(retyped); + if (retyped !== undefined && this.#homeStore(retyped.type) !== this.#homeStore(type)) { + // The write owns the held record's id and would replace it, but the held record is in another store; the push + // reaches only this type's home, so the old record would stay in its own file and load as a second holder. + throw new SchemaConflictError( + schemaIdForEnvelope(written), + [retyped.type, type], + key, + `CultCache holds record "${key}" as type "${retyped.type}" in another store than the home of type "${type}", so the rename cannot replace it; remove the record first.`, + ); + } + return retyped; + } + + // The one refusal of a record held read-only for the slots its payload carries past this build's: a write or a removal of + // its key. It names the type and the counts, never a slot value. A mark that is explained (see #admitEntry) is consented to. + #unreadRefusal(held: HydratedEntry | undefined): SchemaConflictError | undefined { + const unread = held?.unreadSlots; + if (held === undefined || unread === undefined || unread.explained) { + return undefined; + } + return new SchemaConflictError( + held.storedSchemaId ?? held.schemaId ?? held.type, + [held.catalogEntry.schemaName], + held.key, + `${held.catalogEntry.schemaName} record ${held.key} is read-only: ${unread.typeName} was read with ${unread.slotCount} slots where this build declares ${unread.declaredCount} ` + + `(${unread.occurrences} object(s) in the record). Writing it would drop data a newer writer stored; read it with types that declare those slots, or write it from that build.`, + unread, + ); + } + + #refuseUnread(held: HydratedEntry | undefined): void { + const refusal = this.#unreadRefusal(held); + if (refusal !== undefined) { + throw refusal; + } + } + // The one key-identity judge of the cache: may this write land at its key, given what the cache holds there under // every registered type and the keys its stores carry foreign. The store judges the file with the same predicate // (writtenOwns); this judges the cache, which a store-less cache has no other judge for. Returns the held record of @@ -782,18 +892,20 @@ export class CultCache { key: string, ): Promise { const registered = this.#requireDefinition(definition); - const entry = this.#hydrated.entries.get(this.#entryId(definition.type, key)); + // variants:ruling:unheld-removal-refused-at-commit: a key a store carries foreign is refused, a key held by nobody is the + // existing false; a key held as another type is removed by an owner of its id and refused to any other type. + // Removal is by key, in one store call: the record held at the key of this type, else the one of another type this type owns. + const owned = this.#judge(registered, key); + const entry = this.#hydrated.entries.get(this.#entryId(definition.type, key)) ?? owned; if (!entry) { - // variants:ruling:unheld-removal-refused-at-commit: a key a store carries foreign is refused, a key held under - // another type or by nobody is the existing false. - this.#refuseForeignKey(key, definition.type, "removed"); return false; } - const home = this.#homeStore(definition.type); - await home?.delete(this.#hydrated.legacyGlobals.get(definition.type) ?? this.#toEnvelope(entry)); - this.#hydrated.legacyGlobals.delete(definition.type); - this.#removeEntry(this.#hydrated, registered, entry); + // The cache drops the record only once the store has removed it. + const home = this.#homeStore(entry.type); + await home?.delete(this.#hydrated.legacyGlobals.get(entry.type) ?? this.#toEnvelope(entry)); + this.#hydrated.legacyGlobals.delete(entry.type); + this.#removeEntry(this.#hydrated, this.#definitions.get(entry.type)!, entry); return true; } @@ -820,7 +932,7 @@ export class CultCache { return { encode: (value: unknown) => this.#cloneBytes(encodeDocumentPayload(definition, value)), - decode: (payload: DocumentPayload) => decodeDocumentPayload(definition, this.#cloneBytes(payload)), + decode: (payload: DocumentPayload) => decodeDocumentPayloadMarked(definition, this.#cloneBytes(payload)), }; } @@ -1000,6 +1112,7 @@ export class CultCache { registered: RegisteredDefinition, entry: CultCacheEnvelope, value: unknown, + noted?: NotedSlots, ): HydratedEntry { if (registered.global) { const currentGlobalKey = state.globalKeys.get(entry.type); @@ -1020,6 +1133,8 @@ export class CultCache { catalogEntry: registered.catalogEntry, value, lookups, + storedSchemaId: entry.schemaId ?? entry.type, + ...(noted ? { unreadSlots: { ...noted, explained: explainedBy(noted, registered.catalogEntry, entry.schemaId ?? entry.type, entry.catalogEntry) } } : {}), }; } diff --git a/packages/cultcache-ts/src/document-formatter.ts b/packages/cultcache-ts/src/document-formatter.ts index 799b2558..92d45df1 100644 --- a/packages/cultcache-ts/src/document-formatter.ts +++ b/packages/cultcache-ts/src/document-formatter.ts @@ -1,7 +1,7 @@ import { decode, encode } from "@msgpack/msgpack"; import { MAX_PAYLOAD_DEPTH } from "./payload-depth"; -import type { SlotsCodec } from "./codec"; +import { decodeNoting, type NotedSlots, type SlotsCodec } from "./codec"; import type { AnyCultCacheDocumentDefinition, CultCacheDocumentFormatter } from "./types"; import { readWire, writeWire } from "./wire"; @@ -16,6 +16,13 @@ export function resolveDocumentFormatter(definition: AnyCultCacheDocumentDefinit return definition.formatter ?? (definition.codec ? codecFormatter(definition.codec) : plainDocumentFormatter); } +// The one decode of a document's bytes, with the slots it read past. A plain formatter or a user formatter has no slot codec +// and carries everything, so it notes nothing. +export function decodeMarked(definition: AnyCultCacheDocumentDefinition, bytes: Uint8Array): { value: unknown; unreadSlots?: NotedSlots } { + const formatter = resolveDocumentFormatter(definition); + return decodeNoting(() => formatter.decode(bytes)); +} + const plainDocumentFormatter: CultCacheDocumentFormatter = { // The encoder's own depth refusal (100 by default) is set above the limit, so the payload walk decides. encode: (value: unknown) => encode(value, { maxDepth: MAX_PAYLOAD_DEPTH * 2 }), diff --git a/packages/cultcache-ts/src/document.ts b/packages/cultcache-ts/src/document.ts index 10af96a6..8d295bd4 100644 --- a/packages/cultcache-ts/src/document.ts +++ b/packages/cultcache-ts/src/document.ts @@ -1,4 +1,5 @@ -import { resolveDocumentFormatter } from "./document-formatter"; +import type { NotedSlots } from "./codec"; +import { decodeMarked, resolveDocumentFormatter } from "./document-formatter"; import { checkPayloadDepth } from "./payload-depth"; import type { AnyCultCacheDocumentDefinition, @@ -118,10 +119,20 @@ export function decodeDocumentPayload { + return decodeDocumentPayloadMarked(definition, payload).value; +} + +// The same read with the slots it read past (cult-cache-ts slot-readonly). The depth check runs first: a payload past the +// limit is refused, never marked. +export function decodeDocumentPayloadMarked( + definition: TDefinition, + payload: DocumentPayload, +): { value: CultCacheDocumentValue; unreadSlots?: NotedSlots } { const bytes = documentPayloadBytes(payload); if (writesLibraryMessagePack(definition)) checkPayloadDepth(bytes, `document type "${definition.type}" payload`); - const decoded = resolveDocumentFormatter(definition).decode(bytes); - return definition.schema.parse(decoded) as CultCacheDocumentValue; + const { value: decoded, unreadSlots } = decodeMarked(definition, bytes); + const value = definition.schema.parse(decoded) as CultCacheDocumentValue; + return unreadSlots ? { value, unreadSlots } : { value }; } // The one writer: the definition's schema first, then its formatter, codec or plain map. diff --git a/packages/cultcache-ts/src/store-format.ts b/packages/cultcache-ts/src/store-format.ts index acd10207..885b1c8e 100644 --- a/packages/cultcache-ts/src/store-format.ts +++ b/packages/cultcache-ts/src/store-format.ts @@ -1,6 +1,6 @@ import { decode } from "@msgpack/msgpack"; -import type { CultCacheEnvelope } from "./types"; +import type { CultCacheEnvelope, CultCacheUnreadSlots } from "./types"; export const STORE_FORMAT_VERSION = "cultcache.store.v1"; @@ -83,13 +83,16 @@ export class SchemaConflictError extends Error { readonly schemaId: string; readonly schemaNames: string[]; readonly recordKey: string; + /** Set when the refusal is for unread slots; undefined for every other refusal. */ + readonly unreadSlots?: CultCacheUnreadSlots; - constructor(schemaId: string, schemaNames: string[], recordKey: string, message?: string) { + constructor(schemaId: string, schemaNames: string[], recordKey: string, message?: string, unreadSlots?: CultCacheUnreadSlots) { super(message ?? `Schema id "${schemaId}" cannot describe record "${recordKey}": ${schemaNames.map((name) => `"${name}"`).join(", ")}.`); this.name = "SchemaConflictError"; this.schemaId = schemaId; this.schemaNames = schemaNames; this.recordKey = recordKey; + this.unreadSlots = unreadSlots; } } diff --git a/packages/cultcache-ts/src/types.ts b/packages/cultcache-ts/src/types.ts index 6f6c0923..10371053 100644 --- a/packages/cultcache-ts/src/types.ts +++ b/packages/cultcache-ts/src/types.ts @@ -99,6 +99,26 @@ export interface CultCacheEnvelope { catalogEntry?: CultCacheSchemaCatalogEntry; } +/** + * What a payload decode found past the slots this build declares, at any depth: the first type read with extra slots, how many + * slots it carried against how many it declares, and how many objects in the record did. It holds no slot value, so a refusal + * can name it. A record carrying it is read-only unless `explained`. + */ +export interface CultCacheUnreadSlots { + /** The first type read with extra slots (the unit arm's arm name for a unit arm). */ + readonly typeName: string; + readonly slotCount: number; + readonly declaredCount: number; + readonly occurrences: number; + /** True when every mark sat in the document's own object. */ + readonly depth0Only: boolean; + /** + * True only when every mark is at depth 0 and the schema comparison of the id the record was stored under reported every slot + * the mark carries (slotCount is at most the highest reported slot plus one): the loss a compatible-id declaration consents to. + */ + readonly explained: boolean; +} + export interface PushAllOptions { soft?: boolean; } diff --git a/packages/cultcache-ts/test/slot-readonly.test.ts b/packages/cultcache-ts/test/slot-readonly.test.ts new file mode 100644 index 00000000..e3285f07 --- /dev/null +++ b/packages/cultcache-ts/test/slot-readonly.test.ts @@ -0,0 +1,461 @@ +import assert from "node:assert/strict"; +import { copyFile, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; + +import { decode, encode } from "@msgpack/msgpack"; +import { z } from "zod"; + +import { codecs } from "../src"; +import { CultCache } from "../src/cult-cache"; +import { defineDocumentType, documentPayloadBytes, documentPayloadFromBytes } from "../src/document"; +import { SingleFileMessagePackBackingStore, readSingleFileStore } from "../src/single-file-messagepack-backing-store"; +import { SchemaConflictError, StoreUnreadableError } from "../src/store-format"; +import type { CacheBackingStore, CultCacheEnvelope, CultCacheUnreadSlots } from "../src/types"; +import { + type Surface, envelopeOf, golden, hex, memoryStore, read, slotUnitDocument, surfaceCodec, surfaceDeclaringDocument, surfaceDocument, surfaceSchema, vectors, +} from "./union-surface-fixture"; + +// cultlib-gaps:cut:slot-readonly-ts. A payload carrying a slot past its reader's declared slots, at any depth, is read, held +// read-only, and every write or removal of its key is refused before a store is touched. The vectors are written by the C# +// reference: tests/vectors/union-parity/README.md, "Extra trailing slot" and "Slot stores". + +const { slots, string } = codecs; +const SLOT_VALUE = "extra-slot"; + +function mark(typeName: string, slotCount: number, declaredCount: number, depth0Only: boolean, explained = false, occurrences = 1): CultCacheUnreadSlots { + return { typeName, slotCount, declaredCount, occurrences, depth0Only, explained }; +} + +// File, then the mark the README table gives for it. +const PAYLOAD_ROWS: [string, CultCacheUnreadSlots][] = [ + ["extra-slot-record.msgpack", mark("UnionSurface", 8, 7, true)], + ["extra-slot-nested-record.msgpack", mark("Composition", 3, 2, false)], + ["extra-slot-arm.msgpack", mark("Box", 3, 2, false)], + ["extra-slot-list-element.msgpack", mark("Text", 3, 2, false)], + ["depth-skipped-at-limit.msgpack", mark("UnionSurface", 8, 7, true)], +]; + +async function readOnlyFiles(name: string): Promise { + const rows = decode(await read(name)) as unknown[][]; + return rows.filter((row) => row[1] === "read-only").map((row) => row[0] as string); +} + +test("the read-only payload vectors are exactly the ones this walker names: four in outcomes, one in bounds", async () => { + const listed = [...(await readOnlyFiles("outcomes.msgpack")), ...(await readOnlyFiles("bounds-outcomes.msgpack"))]; + assert.deepEqual(listed.sort(), PAYLOAD_ROWS.map(([file]) => file).sort()); +}); + +for (const [file, expected] of PAYLOAD_ROWS) { + test(`${file}: read past the extra slot, held read-only with the mark, every write and removal of the key refused`, async () => { + const document = surfaceDocument(); + const entries = [envelopeOf(await read(file))]; + const before = hex(documentPayloadBytes(entries[0]!.payload)); + const cache = CultCache.builder().withDocumentType(document).withGenericStore(memoryStore(entries)).build(); + await cache.pullAllBackingStores(); + + assert.deepEqual(cache.get(document, "surface"), golden, "the extra slot is read past"); + assert.deepEqual(cache.unreadSlots(document, "surface"), expected); + assert.equal(cache.isWritable(document, "surface"), false); + assert.equal(cache.isWritable(document, "another"), true); + + const refused = (error: unknown) => { + assert.ok(error instanceof SchemaConflictError, "a SchemaConflictError"); + assert.deepEqual(error.unreadSlots, expected); + assert.equal(error.recordKey, "surface"); + assert.ok(error.message.includes(expected.typeName), error.message); + assert.ok(error.message.includes(`read with ${expected.slotCount} slots where this build declares ${expected.declaredCount} (${expected.occurrences} object(s)`), error.message); + assert.ok(!error.message.includes(SLOT_VALUE), "the refusal never holds a slot value"); + return true; + }; + await assert.rejects(() => cache.put(document, "surface", golden), refused); + await assert.rejects(() => cache.update(document, "surface", (value) => ({ ...value!, version: 4 })), refused); + const replacement = envelopeOf(await read("union-surface.msgpack")); + await assert.rejects(() => cache.putEnvelope(document, replacement), refused); + await assert.rejects(() => cache.delete(document, "surface"), refused); + assert.equal(entries.length, 1, "nothing reached the store"); + assert.equal(hex(documentPayloadBytes(entries[0]!.payload)), before, "the held bytes are untouched"); + assert.deepEqual(cache.get(document, "surface"), golden, "the record is still held"); + + await cache.put(document, "another", golden); + assert.equal(entries.length, 2, "another key of the same store writes"); + }); +} + +test("a clean payload and a short one are writable: missing trailing slots keep the defaults and mark nothing", async () => { + const document = surfaceDocument(); + const short = encode([...(decode(await read("union-surface.msgpack")) as unknown[]).slice(0, 6)]); + const entries = [envelopeOf(await read("union-surface.msgpack")), { ...envelopeOf(short), key: "short" }]; + const cache = CultCache.builder().withDocumentType(document).withGenericStore(memoryStore(entries)).build(); + await cache.pullAllBackingStores(); + for (const key of ["surface", "short"]) { + assert.equal(cache.unreadSlots(document, key), undefined); + assert.equal(cache.isWritable(document, key), true); + } + await cache.put(document, "short", golden); +}); + +test("a payload past the nesting limit is refused, never marked", async () => { + const entries = [envelopeOf(await read("depth-skipped-past-limit.msgpack"))]; + const cache = CultCache.builder().withDocumentType(surfaceDocument()).withGenericStore(memoryStore(entries)).build(); + await assert.rejects(() => cache.pullAllBackingStores(), /nesting|depth/iu); +}); + +test("putEnvelope of a payload carrying extra slots lands the bytes and holds the record read-only", async () => { + const document = surfaceDocument(); + const entries: ReturnType[] = []; + const cache = CultCache.builder().withDocumentType(document).withGenericStore(memoryStore(entries)).build(); + const marked = envelopeOf(await read("extra-slot-nested-record.msgpack")); + await cache.putEnvelope(document, marked); + assert.equal(hex(documentPayloadBytes(entries[0]!.payload)), hex(await read("extra-slot-nested-record.msgpack")), "the bytes are carried"); + assert.deepEqual(cache.unreadSlots(document, "surface"), mark("Composition", 3, 2, false)); + assert.equal(cache.isWritable(document, "surface"), false); + await assert.rejects(() => cache.put(document, "surface", golden), SchemaConflictError); +}); + +test("a key a store carries foreign is not writable", async () => { + const document = surfaceDocument(); + const foreign = { ...envelopeOf(await read("union-surface.msgpack")), key: "stranger", type: "tests.unknown", schemaId: "tests.unknown.v1" }; + const cache = CultCache.builder().withDocumentType(document).withGenericStore(memoryStore([foreign])).build(); + await cache.pullAllBackingStores(); + assert.equal(cache.isWritable(document, "stranger"), false); + assert.equal(cache.isWritable(document, "other"), true); +}); + +test("a write by a type that owns the held record's id restamps it, and is refused when that record carries unread slots", async () => { + const typed = (type: string, schemaId: string, compatibleSchemaIds: string[] = []) => + defineDocumentType({ type, schemaId, schemaName: type, schemaVersion: `${type}.v1`, compatibleSchemaIds, schema: surfaceSchema, codec: surfaceCodec, name: "id" }); + const older = typed("tests.surface_older", "tests.surface_older.v1"); + const newer = typed("tests.surface_newer", "tests.surface_newer.v1", ["tests.surface_older.v1"]); + for (const [file, lands] of [["union-surface.msgpack", true], ["extra-slot-record.msgpack", false]] as const) { + const held = { ...envelopeOf(await read(file)), type: older.type, schemaId: "tests.surface_older.v1" }; + const cache = CultCache.builder().withDocumentType(older).withDocumentType(newer).withGenericStore(memoryStore([held])).build(); + await cache.pullAllBackingStores(); + assert.equal(cache.isWritable(newer, "surface"), lands, `${file}: isWritable agrees with the write by the type that owns the id`); + const write = cache.put(newer, "surface", golden); + if (lands) { + await write; + } else { + await assert.rejects(() => write, (error: unknown) => error instanceof SchemaConflictError && error.unreadSlots?.typeName === "UnionSurface"); + } + } +}); + +// The store vectors. TS has no variant records: extra-slot-store.cc carries one (marked-variant), so the whole file is refused +// by name, and the walk runs over the file without that record. +async function copyOf(name: string): Promise { + const path = join(await mkdtemp(join(tmpdir(), "slot-readonly-")), name); + await copyFile(join(vectors, name), path); + return path; +} + +async function withoutVariant(path: string): Promise { + const [format, catalog, records] = decode(await readFile(path)) as [unknown, unknown[], unknown[][]]; + await writeFile(path, encode([format, catalog, records.filter((record) => record[0] !== "marked-variant")])); +} + +type SlotRow = [string, string, boolean, boolean | null, string | null, number | null, number | null, number | null, string | null]; + +async function slotRows(): Promise { + return decode(await read("slot-outcomes.msgpack")) as SlotRow[]; +} + +// The catalog entry the file publishes for a schema: its id and the reference's content hash, which a store rewrites with the +// definition's own. +async function catalogOf(file: string, schemaName: string): Promise<[string, string]> { + const [, catalog] = decode(await read(file)) as [unknown, string[][]]; + const entry = catalog.find((candidate) => candidate[1] === schemaName)!; + return [entry[0]!, entry[3]!]; +} + +async function openStore(file: string, consent: boolean) { + const path = await copyOf(file); + if (file === "extra-slot-store.cc") await withoutVariant(path); + const surface = consent ? surfaceDeclaringDocument() : surfaceDocument(...(await catalogOf(file, "tests.union_surface"))); + const slotUnit = consent ? undefined : slotUnitDocument(...(await catalogOf(file, "tests.slot_unit"))); + const builder = CultCache.builder().withDocumentType(surface).withGenericStore(new SingleFileMessagePackBackingStore(path)); + const cache = (slotUnit ? builder.withDocumentType(slotUnit) : builder).build(); + await cache.pullAllBackingStores(); + return { path, cache, surface, slotUnit }; +} + +test("extra-slot-store.cc carries a variant, which this runtime refuses by name; the rest of the file is walked", async () => { + const path = await copyOf("extra-slot-store.cc"); + assert.throws(() => readSingleFileStore(path), StoreUnreadableError); +}); + +test("every slot-outcomes row holds: the mark, IsWritable, and the removal refused exactly where the record is not writable", async () => { + const rows = await slotRows(); + assert.equal(rows.length, 8); + let walked = 0; + for (const [file, key, writable, explained, typeName, slots, declared, occurrences, flattenRefusedBy] of rows) { + if (flattenRefusedBy !== null) continue; // a variant: this runtime holds none + walked += 1; + const { path, cache, surface, slotUnit } = await openStore(file!, file === "extra-slot-consent.cc"); + const document = key === "unit-arm" ? slotUnit! : surface; + const label = `${file} ${key}`; + const found = cache.unreadSlots(document, key!); + if (typeName === null) { + assert.equal(found, undefined, label); + } else { + assert.ok(found, label); + assert.deepEqual([found.explained, found.typeName, found.slotCount, found.declaredCount, found.occurrences], [explained, typeName, slots, declared, occurrences], label); + } + assert.equal(cache.isWritable(document, key!), writable, label); + + const original = await readFile(path); + if (writable) { + assert.equal(await cache.delete(document, key!), true, label); + } else { + await assert.rejects( + () => cache.delete(document, key!), + (error: unknown) => error instanceof SchemaConflictError && error.unreadSlots?.typeName === typeName && !error.message.includes(SLOT_VALUE), + label, + ); + assert.equal(Buffer.compare(original, await readFile(path)), 0, `${label}: the file is unchanged`); + } + } + assert.equal(walked, 7); +}); + +test("consent: a write of the explained record lands; the over-consent write is refused; the nested mark is refused", async () => { + const consented = await openStore("extra-slot-consent.cc", true); + const value = consented.cache.get(consented.surface, "consented") as Surface; + assert.deepEqual(value, golden); + await consented.cache.put(consented.surface, "consented", value); + assert.equal(consented.cache.unreadSlots(consented.surface, "consented"), undefined, "the write dropped the explained slot and the mark with it"); + assert.equal(readSingleFileStore(consented.path).envelopes.find((e) => e.key === "consented")!.schemaId, "tests.union_surface.v2"); + + for (const key of ["over-consent", "nested"]) { + const refused = await openStore("extra-slot-consent.cc", true); + await assert.rejects( + () => refused.cache.put(refused.surface, key, golden), + (error: unknown) => error instanceof SchemaConflictError && error.schemaId === "tests.union_surface.wide", + `${key}: the refusal names the id the record was stored under`, + ); + } +}); + +test("carried: after writing another record, the marked record's bytes, schema id, stored-at and every catalog entry are the originals", async () => { + const { path, cache, surface } = await openStore("extra-slot-store.cc", false); + const originals = readSingleFileStore(path); + await cache.put(surface, "clean", golden); + const after = readSingleFileStore(path); + for (const key of ["marked", "unit-arm"]) { + const was = originals.envelopes.find((e) => e.key === key)!; + const now = after.envelopes.find((e) => e.key === key)!; + assert.equal(hex(documentPayloadBytes(now.payload)), hex(documentPayloadBytes(was.payload)), key); + assert.equal(now.schemaId, was.schemaId, key); + assert.equal(now.storedAt, was.storedAt, key); + assert.deepEqual(now.catalogEntry, was.catalogEntry, key); + } +}); + +// A writer that adds a slot and keeps its schema id (TS ids are hand-written; C# ids hash the members): the reader of that +// same id has declared nothing, so it consents to nothing. +test("a wider writer under the reader's own schema id is read-only, and a real round trip does not shed the slot", async () => { + const doc = (columns: 1 | 2, compatibleSchemaIds: string[] = []) => defineDocumentType({ + type: "tests.own_id", + schemaId: "tests.own_id.v1", + schemaName: "tests.own_id", + compatibleSchemaIds, + schema: z.object({ n: z.string(), extra: z.string().optional() }), + codec: columns === 2 + ? slots<{ n: string; extra: string }>({ 0: ["n", string], 1: ["extra", string] }, "Own") + : slots<{ n: string }>({ 0: ["n", string] }, "Own"), + name: "n", + }); + const path = join(await mkdtemp(join(tmpdir(), "slot-readonly-")), "own.cc"); + const wide = doc(2); + const writer = CultCache.builder().withDocumentType(wide).withGenericStore(new SingleFileMessagePackBackingStore(path)).build(); + await writer.put(wide, "k", { n: "n1", extra: "kept" }); + const bytes = await readFile(path); + + for (const compatible of [[], ["tests.own_id.v1"]]) { + const reader = doc(1, compatible); + const cache = CultCache.builder().withDocumentType(reader).withGenericStore(new SingleFileMessagePackBackingStore(path)).build(); + await cache.pullAllBackingStores(); + assert.deepEqual(cache.unreadSlots(reader, "k"), mark("Own", 2, 1, true), "unexplained: the arrived id is the reader's own"); + assert.equal(cache.isWritable(reader, "k"), false); + await assert.rejects(() => cache.put(reader, "k", { n: "n2" }), SchemaConflictError); + await assert.rejects(() => cache.delete(reader, "k"), SchemaConflictError); + assert.equal(Buffer.compare(bytes, await readFile(path)), 0, "the file still holds the slot"); + } +}); + +test("a record that arrived under an id the reader does not list compatible is never consented to", async () => { + const path = await copyOf("extra-slot-consent.cc"); + const reader = surfaceDocument("tests.union_surface.v2"); // resolves by type name; lists nothing + const cache = CultCache.builder().withDocumentType(reader).withGenericStore(new SingleFileMessagePackBackingStore(path)).build(); + await cache.pullAllBackingStores(); + assert.equal(cache.unreadSlots(reader, "consented")?.explained, false); + assert.equal(cache.isWritable(reader, "consented"), false); +}); + +// isWritable is the write's own judge, not a second one: for every key state it answers what put does. +test("isWritable equals the outcome of put for every key state the write judges", async () => { + const typed = (type: string, schemaId: string, extra: Record = {}) => + defineDocumentType({ type, schemaId, schemaName: type, schemaVersion: `${type}.v1`, schema: surfaceSchema, codec: surfaceCodec, name: "id", ...extra }); + const older = typed("tests.surface_older", "tests.surface_older.v1"); + const newer = typed("tests.surface_newer", "tests.surface_newer.v1", { compatibleSchemaIds: ["tests.surface_older.v1"] }); + const stranger = typed("tests.surface_stranger", "tests.surface_stranger.v1"); + const global = typed("tests.surface_global", "tests.surface_global.v1", { global: true }); + const clean = (key: string, type: string, schemaId: string) => ({ ...envelopeOf(new Uint8Array()), key, type, schemaId }); + const held = async (key: string, file: string, type: string, schemaId: string) => + ({ ...envelopeOf(await read(file)), key, type, schemaId }); + + type Row = { name: string; build: () => Promise<{ cache: CultCache; target: typeof older; key: string }> }; + const rows: Row[] = [ + { name: "owned clean record in the writer's own store", build: async () => { + const cache = CultCache.builder().withDocumentType(older).withDocumentType(newer) + .withGenericStore(memoryStore([await held("k", "union-surface.msgpack", older.type, older.schemaId!)])).build(); + return { cache, target: newer, key: "k" }; + } }, + { name: "owned record held in a store other than the writer's home", build: async () => { + const cache = CultCache.builder().withDocumentType(older).withDocumentType(newer) + .withBackingStore(memoryStore([await held("k", "union-surface.msgpack", older.type, older.schemaId!)]), older) + .withBackingStore(memoryStore([]), newer).build(); + return { cache, target: newer, key: "k" }; + } }, + { name: "a record of a type that does not own the writer's id", build: async () => { + const cache = CultCache.builder().withDocumentType(older).withDocumentType(stranger) + .withGenericStore(memoryStore([await held("k", "union-surface.msgpack", older.type, older.schemaId!)])).build(); + return { cache, target: stranger, key: "k" }; + } }, + { name: "a global type at a key other than the global key", build: async () => { + const cache = CultCache.builder().withDocumentType(global).withGenericStore(memoryStore([])).build(); + return { cache, target: global, key: "x" }; + } }, + { name: "a key a store carries foreign", build: async () => { + const cache = CultCache.builder().withDocumentType(older) + .withGenericStore(memoryStore([{ ...clean("k", "tests.unknown", "tests.unknown.v1"), payload: documentPayloadFromBytes(await read("union-surface.msgpack")) }])).build(); + return { cache, target: older, key: "k" }; + } }, + { name: "a marked record of the writer's own type", build: async () => { + const cache = CultCache.builder().withDocumentType(older) + .withGenericStore(memoryStore([await held("k", "extra-slot-record.msgpack", older.type, older.schemaId!)])).build(); + return { cache, target: older, key: "k" }; + } }, + { name: "an unheld key", build: async () => ({ cache: CultCache.builder().withDocumentType(older).withGenericStore(memoryStore([])).build(), target: older, key: "k" }) }, + ]; + const outcomes: [string, boolean, boolean][] = []; + for (const row of rows) { + const { cache, target, key } = await row.build(); + await cache.pullAllBackingStores(); + const predicted = cache.isWritable(target, key); + const landed = await cache.put(target, key === "x" ? "x" : key, golden).then(() => true, () => false); + assert.equal(predicted, landed, row.name); + outcomes.push([row.name, predicted, landed]); + } + assert.deepEqual(outcomes.map(([, writable]) => writable), [true, false, false, false, false, false, true]); + // The same judge refuses a removal of a key held read-only or carried foreign. + const removals = CultCache.builder().withDocumentType(older) + .withGenericStore(memoryStore([await held("k", "extra-slot-record.msgpack", older.type, older.schemaId!)])).build(); + await removals.pullAllBackingStores(); + await assert.rejects(() => removals.delete(older, "k"), SchemaConflictError); + const stranger2 = CultCache.builder().withDocumentType(older) + .withGenericStore(memoryStore([{ ...envelopeOf(await read("union-surface.msgpack")), key: "f", type: "tests.unknown", schemaId: "tests.unknown.v1" }])).build(); + await stranger2.pullAllBackingStores(); + await assert.rejects(() => stranger2.delete(older, "f"), SchemaConflictError); +}); + +// A removal is by key (C# Remove): the owner of a held record's id removes the one record that exists, in one store call and +// with the cache dropped only once the store has; a type that does not own it is refused like put, and isWritable agrees. +test("delete of a key held as another type: the owner removes it once, a stranger is refused, a failing store leaves cache and store agreeing", async () => { + const typed = (type: string, schemaId: string, extra: Record = {}) => + defineDocumentType({ type, schemaId, schemaName: type, schemaVersion: `${type}.v1`, schema: surfaceSchema, codec: surfaceCodec, name: "id", ...extra }); + const older = typed("tests.surface_older", "tests.surface_older.v1"); + const newer = typed("tests.surface_newer", "tests.surface_newer.v1", { compatibleSchemaIds: ["tests.surface_older.v1"] }); + const stranger = typed("tests.surface_stranger", "tests.surface_stranger.v1"); + const heldRecord = async (): Promise => ({ ...envelopeOf(await read("union-surface.msgpack")), key: "k", type: older.type, schemaId: older.schemaId }); + const open = async (failing = false) => { + const rows = [await heldRecord()]; + const calls: string[] = []; + const store: CacheBackingStore = { + async pullAll() { return rows.map((row) => ({ ...row })); }, + async push(entry) { rows.push(entry); }, + async delete(entry) { + calls.push(entry.key); + if (failing) throw new Error("store refused the removal"); + rows.splice(rows.findIndex((row) => row.key === entry.key), 1); + }, + }; + const cache = CultCache.builder().withDocumentType(older).withDocumentType(newer).withDocumentType(stranger).withGenericStore(store).build(); + await cache.pullAllBackingStores(); + return { cache, rows, calls }; + }; + + const owner = await open(); + assert.equal(owner.cache.isWritable(newer, "k"), true); + assert.equal(await owner.cache.delete(newer, "k"), true, "the owner's removal removes the one record"); + assert.deepEqual([owner.rows.length, owner.calls.length, owner.cache.get(older, "k")], [0, 1, undefined], "one store call, record gone from store and cache"); + + const refused = await open(); + assert.equal(refused.cache.isWritable(stranger, "k"), false); + await assert.rejects(() => refused.cache.delete(stranger, "k"), SchemaConflictError); + assert.deepEqual([refused.rows.length, refused.calls.length, refused.cache.get(older, "k") !== undefined], [1, 0, true]); + + const failing = await open(true); + await assert.rejects(() => failing.cache.delete(newer, "k"), /store refused/u); + assert.deepEqual([failing.rows.length, failing.calls.length, failing.cache.get(older, "k") !== undefined], [1, 1, true], "the store failed: neither side dropped it"); + + const same = await open(); + assert.equal(await same.cache.delete(older, "k"), true); + assert.equal(await same.cache.delete(older, "k"), false, "an unheld key is still the plain false"); +}); + +// The removal form's three finer behaviours (cultlib-gaps:finding:cut-slot-readonly-ts.s4.removal-form-unpinned). +test("an owner's delete: refused for a marked record, taken from the held record's store, and clears the held type's lookups", async () => { + const typed = (type: string, schemaId: string, extra: Record = {}) => + defineDocumentType({ type, schemaId, schemaName: type, schemaVersion: `${type}.v1`, schema: surfaceSchema, codec: surfaceCodec, name: "id", ...extra }); + const older = typed("tests.surface_older", "tests.surface_older.v1", { indexes: { byVersion: (value: Surface) => String(value.version) } }); + const newer = typed("tests.surface_newer", "tests.surface_newer.v1", { compatibleSchemaIds: ["tests.surface_older.v1"] }); + const heldIn = async (file: string): Promise => + ({ ...envelopeOf(await read(file)), key: "k", type: older.type, schemaId: older.schemaId }); + const recording = (rows: CultCacheEnvelope[]) => { + const removed: string[] = []; + const store: CacheBackingStore = { + async pullAll() { return rows.map((row) => ({ ...row })); }, + async push(entry) { rows.push(entry); }, + async delete(entry) { removed.push(entry.key); rows.splice(rows.findIndex((row) => row.key === entry.key), 1); }, + }; + return { store, removed, rows }; + }; + + // 1. The owner may not remove a record carrying unread slots nothing consents to. + const marked = recording([await heldIn("extra-slot-record.msgpack")]); + const markedCache = CultCache.builder().withDocumentType(older).withDocumentType(newer).withGenericStore(marked.store).build(); + await markedCache.pullAllBackingStores(); + await assert.rejects(() => markedCache.delete(newer, "k"), (error: unknown) => error instanceof SchemaConflictError && error.unreadSlots?.typeName === "UnionSurface"); + assert.deepEqual([marked.rows.length, marked.removed.length, markedCache.get(older, "k") !== undefined], [1, 0, true]); + + // 2. The removal goes to the store of the record that exists, not the deleting type's; there the write form is false (it would + // push to the wrong store) while the removal succeeds. + const olderStore = recording([await heldIn("union-surface.msgpack")]); + const newerStore = recording([]); + const split = CultCache.builder().withDocumentType(older).withDocumentType(newer) + .withBackingStore(olderStore.store, older).withBackingStore(newerStore.store, newer).build(); + await split.pullAllBackingStores(); + assert.equal(split.isWritable(newer, "k"), false, "isWritable answers the write form: the rename cannot replace a record in another store"); + assert.equal(await split.delete(newer, "k"), true); + assert.deepEqual([olderStore.removed, newerStore.removed, split.get(older, "k")], [["k"], [], undefined]); + + // 3. The held type's name and index lookups are cleared with the record. + const single = recording([await heldIn("union-surface.msgpack")]); + const cache = CultCache.builder().withDocumentType(older).withDocumentType(newer).withGenericStore(single.store).build(); + await cache.pullAllBackingStores(); + assert.equal(cache.getKeyByName(older, "surface"), "k"); + assert.equal(cache.getKeyByIndex(older, "byVersion", "3"), "k"); + await cache.delete(newer, "k"); + assert.equal(cache.getKeyByName(older, "surface"), undefined); + assert.equal(cache.getKeyByIndex(older, "byVersion", "3"), undefined); + assert.equal(cache.getAll(older).length, 0); +}); + +test("a global type at a key other than the global key: isWritable is false and delete is the plain false of an unheld key", async () => { + const global = defineDocumentType({ type: "tests.surface_global", schemaId: "tests.surface_global.v1", schemaName: "tests.surface_global", schemaVersion: "tests.surface_global.v1", schema: surfaceSchema, codec: surfaceCodec, name: "id", global: true }); + const cache = CultCache.builder().withDocumentType(global).withGenericStore(memoryStore([])).build(); + assert.equal(cache.isWritable(global, "x"), false); + assert.equal(await cache.delete(global, "x"), false); +}); diff --git a/packages/cultcache-ts/test/union-parity.test.ts b/packages/cultcache-ts/test/union-parity.test.ts index 46dcb42f..d688cb74 100644 --- a/packages/cultcache-ts/test/union-parity.test.ts +++ b/packages/cultcache-ts/test/union-parity.test.ts @@ -1,8 +1,7 @@ import assert from "node:assert/strict"; -import { existsSync } from "node:fs"; import { mkdtemp, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { join } from "node:path"; import { test } from "node:test"; import { decode, encode } from "@msgpack/msgpack"; @@ -22,190 +21,14 @@ import { encodeDocumentPayload, } from "../src/document"; import { SingleFileMessagePackBackingStore } from "../src/single-file-messagepack-backing-store"; -import type { AnyCultCacheDocumentDefinition, CacheBackingStore, CultCacheEnvelope } from "../src/types"; +import type { AnyCultCacheDocumentDefinition, CultCacheEnvelope } from "../src/types"; + +import { + CANARY, type Node, type Surface, golden, hex, node, outcome, read, surfaceCodec, surfaceDocument, surfaceSchema, vectors, envelopeOf, memoryStore, +} from "./union-surface-fixture"; const { array, boolean, bytes, float32, float64, int32, int64, lazy, list, nullable, slots, string, union, unit } = codecs; -// The names the C# reference gives the fixture's types (CultSchemaTypeNames.FromType), as union-surface.cc carries them. -const CS = "GameCult.Caching.Tests.UnionParityVectorTests+"; - -// The golden is tests/vectors/union-parity, written by the C# reference. Everything below that is bytes or an expected -// outcome is read from those committed files; only the value the payload must decode to is spelled here. -function findVectors(from: string): string { - for (let dir = from; ; dir = resolve(dir, "..")) { - if (existsSync(join(dir, "tests", "vectors", "union-parity"))) return join(dir, "tests", "vectors", "union-parity"); - if (resolve(dir, "..") === dir) throw new Error("tests/vectors/union-parity not found above the test."); - } -} -const vectors = findVectors(__dirname); -const read = async (name: string) => new Uint8Array(await readFile(join(vectors, name))); - -// The string the C# writer put inside every refusal payload, to prove a refusal never echoes the payload. -const CANARY = "CANARY-union-golden-4c2a"; - -type Node = - | { kind: "Box"; id: string; children: (Node | null)[] } - | { kind: "Text"; id: string; content: string } - | { kind: "Use"; id: string; composition: Composition; slots: (Node | null)[] } - | { kind: "Image"; id: string; uri: string; alt: string } - | { kind: "Wrap"; inner: Node | null; id: string }; -interface Composition { name: string; body: Node | null } -type Outcome = { kind: "Pending"; message: string } | { kind: "Completed"; sourceVersion: number } | { kind: "Denied" } | { kind: "Floor" } | { kind: "Ceiling" }; -// The golden's Outcome: the arms of a list carry an element id, so Denied, Floor and Ceiling are one-slot records. -type GoldenOutcome = - | { kind: "Pending"; message: string; id: string } - | { kind: "Completed"; sourceVersion: number; id: string } - | { kind: "Denied"; id: string } - | { kind: "Floor"; id: string } - | { kind: "Ceiling"; id: string }; -type Item = { kind: "Gem"; name: string } | { kind: "Tool"; name: string; uses: number } | { kind: "Part"; name: string; count: number }; -type CraftedItem = { kind: "Tool"; name: string; uses: number } | { kind: "Part"; name: string; count: number }; -interface Surface { - id: string; - version: number; - root: Node | null; - outcomes: (GoldenOutcome | null)[]; - part: Item | null; - crafted: CraftedItem | null; - missing: Node | null; -} - -const composition = slots({ 0: ["name", string], 1: ["body", lazy(() => node)] }, `${CS}Composition`); -const node: Codec = union("Node", { - 0: ["Box", slots<{ id: string; children: (Node | null)[] }>({ 0: ["id", string], 1: ["children", list(lazy(() => node))] })], - 1: ["Text", slots<{ id: string; content: string }>({ 0: ["id", string], 1: ["content", string] })], - 2: ["Use", slots<{ id: string; composition: Composition; slots: (Node | null)[] }>({ - 0: ["id", string], 1: ["composition", composition], 2: ["slots", list(lazy(() => node))], - })], - 5: ["Image", slots<{ id: string; uri: string; alt: string }>({ 0: ["id", string], 1: ["uri", string], 3: ["alt", string] })], - 6: ["Wrap", slots<{ inner: Node | null; id: string }>({ 0: ["inner", lazy(() => node)], 1: ["id", string] })], -}, `${CS}Node`); -const outcome = union("Outcome", { - 0: ["Pending", slots<{ message: string }>({ 0: ["message", string] })], - 1: ["Completed", slots<{ sourceVersion: number }>({ 0: ["sourceVersion", int32] })], - 2: ["Denied", unit], - [-2147483648]: ["Floor", unit], - 2147483647: ["Ceiling", unit], -}, `${CS}Outcome`); -const idOnly = slots<{ id: string }>({ 0: ["id", string] }); -const goldenOutcome = union("Outcome", { - 0: ["Pending", slots<{ message: string; id: string }>({ 0: ["message", string], 1: ["id", string] })], - 1: ["Completed", slots<{ sourceVersion: number; id: string }>({ 0: ["sourceVersion", int32], 1: ["id", string] })], - 2: ["Denied", idOnly], - [-2147483648]: ["Floor", idOnly], - 2147483647: ["Ceiling", idOnly], -}, `${CS}Outcome`); -const toolArm = slots<{ name: string; uses: number }>({ 0: ["name", string], 1: ["uses", int32] }); -const partArm = slots<{ name: string; count: number }>({ 0: ["name", string], 1: ["count", int32] }); -const item = union("Item", { - 0: ["Gem", slots<{ name: string }>({ 0: ["name", string] })], - 1: ["Tool", toolArm], - 2: ["Part", partArm], -}, `${CS}Item`); -const craftedItem = union("CraftedItem", { 0: ["Tool", toolArm], 1: ["Part", partArm] }, `${CS}CraftedItem`); -const surfaceCodec = slots({ - 0: ["id", string], - 1: ["version", int32], - 2: ["root", node], - 3: ["outcomes", list(goldenOutcome)], - 4: ["part", item], - 5: ["crafted", craftedItem], - 6: ["missing", node, null], -}, `${CS}UnionSurface`); - -const nodeSchema: z.ZodType = z.lazy(() => - z.union([ - z.null(), - z.discriminatedUnion("kind", [ - z.object({ kind: z.literal("Box"), id: z.string(), children: z.array(nodeSchema) }), - z.object({ kind: z.literal("Text"), id: z.string(), content: z.string() }), - z.object({ - kind: z.literal("Use"), - id: z.string(), - composition: z.object({ name: z.string(), body: nodeSchema }), - slots: z.array(nodeSchema), - }), - z.object({ kind: z.literal("Image"), id: z.string(), uri: z.string(), alt: z.string() }), - z.object({ kind: z.literal("Wrap"), inner: nodeSchema, id: z.string() }), - ]), - ])); -const surfaceSchema = z.object({ - id: z.string(), - version: z.number(), - root: nodeSchema, - outcomes: z.array(z.union([ - z.null(), - z.discriminatedUnion("kind", [ - z.object({ kind: z.literal("Pending"), message: z.string(), id: z.string() }), - z.object({ kind: z.literal("Completed"), sourceVersion: z.number(), id: z.string() }), - z.object({ kind: z.literal("Denied"), id: z.string() }), - z.object({ kind: z.literal("Floor"), id: z.string() }), - z.object({ kind: z.literal("Ceiling"), id: z.string() }), - ]), - ])), - part: z.union([z.null(), z.discriminatedUnion("kind", [ - z.object({ kind: z.literal("Gem"), name: z.string() }), - z.object({ kind: z.literal("Tool"), name: z.string(), uses: z.number() }), - z.object({ kind: z.literal("Part"), name: z.string(), count: z.number() }), - ])]), - crafted: z.union([z.null(), z.discriminatedUnion("kind", [ - z.object({ kind: z.literal("Tool"), name: z.string(), uses: z.number() }), - z.object({ kind: z.literal("Part"), name: z.string(), count: z.number() }), - ])]), - missing: nodeSchema, -}) as unknown as z.ZodType; - -function surfaceDocument(schemaId?: string) { - return defineDocumentType({ - type: "tests.union_surface", - schemaId: schemaId ?? "tests.union_surface.v1", - schemaName: "tests.union_surface", - schemaVersion: "tests.union_surface.v1", - schema: surfaceSchema, - codec: surfaceCodec, - name: "id", - }); -} - -const golden: Surface = { - id: "surface", - version: 3, - root: { - kind: "Box", - id: "0000000000c0", - children: [ - { kind: "Text", id: "0000000000c1", content: "hello" }, - { - kind: "Use", - id: "0000000000c2", - composition: { - name: "comp", - body: { - kind: "Box", - id: "0000000000d0", - children: [ - { kind: "Text", id: "0000000000d1", content: "x" }, - { kind: "Image", id: "0000000000d2", uri: "uri://a", alt: "alt" }, - ], - }, - }, - slots: [{ kind: "Text", id: "0000000000e1", content: "slot" }, null, { kind: "Box", id: "0000000000e2", children: [] }], - }, - { kind: "Image", id: "0000000000c3", uri: "uri://b", alt: "" }, - ], - }, - outcomes: [ - { kind: "Pending", message: "wait", id: "0000000000a1" }, - { kind: "Completed", sourceVersion: 7, id: "0000000000a2" }, - { kind: "Denied", id: "0000000000a3" }, - ], - part: { kind: "Tool", name: "hammer", uses: 12 }, - crafted: { kind: "Part", name: "gear", count: 4 }, - missing: null, -}; - -const hex = (data: Uint8Array) => Buffer.from(data).toString("hex"); - async function outcomeEntries() { const entries = decode(await read("outcomes.msgpack")) as [string, string, string, number | null, string][]; return entries.map(([file, outcome, unionName, key, detail]) => ({ file, outcome, unionName, key, detail })); @@ -729,19 +552,6 @@ test("the Int32 edges of an arm key: the largest is an arm, one past either end assert.throws(() => edge.decode(readWire(fromHex("92d3ffffffff8000000090"))), { message: "Union U: unknown arm key -2147483648 (declared keys: 2147483647)." }); }); -function envelopeOf(payload: Uint8Array): CultCacheEnvelope { - return { key: "surface", type: "tests.union_surface", payload: documentPayloadFromBytes(payload), storedAt: new Date().toISOString() }; -} - -function memoryStore(entries: CultCacheEnvelope[]): CacheBackingStore & { entries: CultCacheEnvelope[] } { - return { - entries, - async pullAll() { return entries.map((entry) => ({ ...entry })); }, - async push(entry) { entries.push(entry); }, - async delete() {}, - }; -} - test("arm keys span Int32 and are listed ascending; slot keys are non-negative Int32", () => { const keys = union<{ kind: "High" | "Zero" | "Neg" | "Low" }>("U", { 2147483647: ["High", unit], diff --git a/packages/cultcache-ts/test/union-surface-fixture.ts b/packages/cultcache-ts/test/union-surface-fixture.ts new file mode 100644 index 00000000..1b556eb1 --- /dev/null +++ b/packages/cultcache-ts/test/union-surface-fixture.ts @@ -0,0 +1,240 @@ +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; + +import { z } from "zod"; + +import { codecs } from "../src"; +import type { Codec } from "../src/codec"; +import { defineDocumentType, documentPayloadFromBytes } from "../src/document"; +import type { CacheBackingStore, CultCacheEnvelope } from "../src/types"; + +// The union-surface fixture: the codecs, schema and value tests/vectors/union-parity was written from, shared by the +// union-parity walker and the slot-readonly walker so the shape is spelled once. +const { list, int32, lazy, slots, string, union, unit } = codecs; + + +// The names the C# reference gives the fixture's types (CultSchemaTypeNames.FromType), as union-surface.cc carries them. +export const CS = "GameCult.Caching.Tests.UnionParityVectorTests+"; + +// The golden is tests/vectors/union-parity, written by the C# reference. Everything below that is bytes or an expected +// outcome is read from those committed files; only the value the payload must decode to is spelled here. +export function findVectors(from: string): string { + for (let dir = from; ; dir = resolve(dir, "..")) { + if (existsSync(join(dir, "tests", "vectors", "union-parity"))) return join(dir, "tests", "vectors", "union-parity"); + if (resolve(dir, "..") === dir) throw new Error("tests/vectors/union-parity not found above the test."); + } +} +export const vectors = findVectors(__dirname); +export const read = async (name: string) => new Uint8Array(await readFile(join(vectors, name))); + +// The string the C# writer put inside every refusal payload, to prove a refusal never echoes the payload. +export const CANARY = "CANARY-union-golden-4c2a"; + +export type Node = + | { kind: "Box"; id: string; children: (Node | null)[] } + | { kind: "Text"; id: string; content: string } + | { kind: "Use"; id: string; composition: Composition; slots: (Node | null)[] } + | { kind: "Image"; id: string; uri: string; alt: string } + | { kind: "Wrap"; inner: Node | null; id: string }; +export interface Composition { name: string; body: Node | null } +export type Outcome = { kind: "Pending"; message: string } | { kind: "Completed"; sourceVersion: number } | { kind: "Denied" } | { kind: "Floor" } | { kind: "Ceiling" }; +// The golden's Outcome: the arms of a list carry an element id, so Denied, Floor and Ceiling are one-slot records. +export type GoldenOutcome = + | { kind: "Pending"; message: string; id: string } + | { kind: "Completed"; sourceVersion: number; id: string } + | { kind: "Denied"; id: string } + | { kind: "Floor"; id: string } + | { kind: "Ceiling"; id: string }; +export type Item = { kind: "Gem"; name: string } | { kind: "Tool"; name: string; uses: number } | { kind: "Part"; name: string; count: number }; +export type CraftedItem = { kind: "Tool"; name: string; uses: number } | { kind: "Part"; name: string; count: number }; +export interface Surface { + id: string; + version: number; + root: Node | null; + outcomes: (GoldenOutcome | null)[]; + part: Item | null; + crafted: CraftedItem | null; + missing: Node | null; +} + +export const composition = slots({ 0: ["name", string], 1: ["body", lazy(() => node)] }, `${CS}Composition`); +export const node: Codec = union("Node", { + 0: ["Box", slots<{ id: string; children: (Node | null)[] }>({ 0: ["id", string], 1: ["children", list(lazy(() => node))] }, `${CS}Box`)], + 1: ["Text", slots<{ id: string; content: string }>({ 0: ["id", string], 1: ["content", string] }, `${CS}Text`)], + 2: ["Use", slots<{ id: string; composition: Composition; slots: (Node | null)[] }>({ + 0: ["id", string], 1: ["composition", composition], 2: ["slots", list(lazy(() => node))], + })], + 5: ["Image", slots<{ id: string; uri: string; alt: string }>({ 0: ["id", string], 1: ["uri", string], 3: ["alt", string] })], + 6: ["Wrap", slots<{ inner: Node | null; id: string }>({ 0: ["inner", lazy(() => node)], 1: ["id", string] })], +}, `${CS}Node`); +export const outcome = union("Outcome", { + 0: ["Pending", slots<{ message: string }>({ 0: ["message", string] })], + 1: ["Completed", slots<{ sourceVersion: number }>({ 0: ["sourceVersion", int32] })], + 2: ["Denied", unit], + [-2147483648]: ["Floor", unit], + 2147483647: ["Ceiling", unit], +}, `${CS}Outcome`); +export const idOnly = slots<{ id: string }>({ 0: ["id", string] }); +export const goldenOutcome = union("Outcome", { + 0: ["Pending", slots<{ message: string; id: string }>({ 0: ["message", string], 1: ["id", string] })], + 1: ["Completed", slots<{ sourceVersion: number; id: string }>({ 0: ["sourceVersion", int32], 1: ["id", string] })], + 2: ["Denied", idOnly], + [-2147483648]: ["Floor", idOnly], + 2147483647: ["Ceiling", idOnly], +}, `${CS}Outcome`); +export const toolArm = slots<{ name: string; uses: number }>({ 0: ["name", string], 1: ["uses", int32] }); +export const partArm = slots<{ name: string; count: number }>({ 0: ["name", string], 1: ["count", int32] }); +export const item = union("Item", { + 0: ["Gem", slots<{ name: string }>({ 0: ["name", string] })], + 1: ["Tool", toolArm], + 2: ["Part", partArm], +}, `${CS}Item`); +export const craftedItem = union("CraftedItem", { 0: ["Tool", toolArm], 1: ["Part", partArm] }, `${CS}CraftedItem`); +export const surfaceCodecOf = (typeName: string) => slots({ + 0: ["id", string], + 1: ["version", int32], + 2: ["root", node], + 3: ["outcomes", list(goldenOutcome)], + 4: ["part", item], + 5: ["crafted", craftedItem], + 6: ["missing", node, null], +}, typeName); +export const surfaceCodec = surfaceCodecOf(`${CS}UnionSurface`); + +export const nodeSchema: z.ZodType = z.lazy(() => + z.union([ + z.null(), + z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("Box"), id: z.string(), children: z.array(nodeSchema) }), + z.object({ kind: z.literal("Text"), id: z.string(), content: z.string() }), + z.object({ + kind: z.literal("Use"), + id: z.string(), + composition: z.object({ name: z.string(), body: nodeSchema }), + slots: z.array(nodeSchema), + }), + z.object({ kind: z.literal("Image"), id: z.string(), uri: z.string(), alt: z.string() }), + z.object({ kind: z.literal("Wrap"), inner: nodeSchema, id: z.string() }), + ]), + ])); +export const surfaceSchema = z.object({ + id: z.string(), + version: z.number(), + root: nodeSchema, + outcomes: z.array(z.union([ + z.null(), + z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("Pending"), message: z.string(), id: z.string() }), + z.object({ kind: z.literal("Completed"), sourceVersion: z.number(), id: z.string() }), + z.object({ kind: z.literal("Denied"), id: z.string() }), + z.object({ kind: z.literal("Floor"), id: z.string() }), + z.object({ kind: z.literal("Ceiling"), id: z.string() }), + ]), + ])), + part: z.union([z.null(), z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("Gem"), name: z.string() }), + z.object({ kind: z.literal("Tool"), name: z.string(), uses: z.number() }), + z.object({ kind: z.literal("Part"), name: z.string(), count: z.number() }), + ])]), + crafted: z.union([z.null(), z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("Tool"), name: z.string(), uses: z.number() }), + z.object({ kind: z.literal("Part"), name: z.string(), count: z.number() }), + ])]), + missing: nodeSchema, +}) as unknown as z.ZodType; + +// The reference's SurfaceDeclaring: the same layout, schema tests.union_surface.v2, listing the older wide id compatible. +export function surfaceDeclaringDocument() { + return defineDocumentType({ + type: "tests.union_surface", + schemaId: "tests.union_surface.v2", + schemaName: "tests.union_surface", + schemaVersion: "tests.union_surface.v2", + compatibleSchemaIds: ["tests.union_surface.wide"], + schema: surfaceSchema, + codec: surfaceCodecOf(`${CS}SurfaceDeclaring`), + name: "id", + }); +} + +export function surfaceDocument(schemaId?: string, contentHash?: string) { + return defineDocumentType({ + ...(contentHash === undefined ? {} : { contentHash }), + type: "tests.union_surface", + schemaId: schemaId ?? "tests.union_surface.v1", + schemaName: "tests.union_surface", + schemaVersion: "tests.union_surface.v1", + schema: surfaceSchema, + codec: surfaceCodec, + name: "id", + }); +} + +export const golden: Surface = { + id: "surface", + version: 3, + root: { + kind: "Box", + id: "0000000000c0", + children: [ + { kind: "Text", id: "0000000000c1", content: "hello" }, + { + kind: "Use", + id: "0000000000c2", + composition: { + name: "comp", + body: { + kind: "Box", + id: "0000000000d0", + children: [ + { kind: "Text", id: "0000000000d1", content: "x" }, + { kind: "Image", id: "0000000000d2", uri: "uri://a", alt: "alt" }, + ], + }, + }, + slots: [{ kind: "Text", id: "0000000000e1", content: "slot" }, null, { kind: "Box", id: "0000000000e2", children: [] }], + }, + { kind: "Image", id: "0000000000c3", uri: "uri://b", alt: "" }, + ], + }, + outcomes: [ + { kind: "Pending", message: "wait", id: "0000000000a1" }, + { kind: "Completed", sourceVersion: 7, id: "0000000000a2" }, + { kind: "Denied", id: "0000000000a3" }, + ], + part: { kind: "Tool", name: "hammer", uses: 12 }, + crafted: { kind: "Part", name: "gear", count: 4 }, + missing: null, +}; +export const hex = (data: Uint8Array) => Buffer.from(data).toString("hex"); + +// tests.slot_unit: a document [Name, Outcome: UnitOutcome], whose one arm Denied2 is a unit arm (extra-slot-store.cc, record +// unit-arm). The reader's names are the reference's: a mark names Denied2. +export const unitOutcome = union<{ kind: "Denied2" }>("UnitOutcome", { 2: ["Denied2", unit] }, `${CS}UnitOutcome`); +export function slotUnitDocument(schemaId: string, contentHash?: string) { + return defineDocumentType({ + ...(contentHash === undefined ? {} : { contentHash }), + type: "tests.slot_unit", + schemaId, + schemaName: "tests.slot_unit", + schemaVersion: "tests.slot_unit.v1", + schema: z.object({ name: z.string(), outcome: z.union([z.null(), z.object({ kind: z.literal("Denied2") })]) }), + codec: slots<{ name: string; outcome: { kind: "Denied2" } | null }>({ 0: ["name", string], 1: ["outcome", unitOutcome] }, `${CS}SlotUnit`), + name: "name", + }); +} + +export function envelopeOf(payload: Uint8Array): CultCacheEnvelope { + return { key: "surface", type: "tests.union_surface", payload: documentPayloadFromBytes(payload), storedAt: new Date().toISOString() }; +} + +export function memoryStore(entries: CultCacheEnvelope[]): CacheBackingStore & { entries: CultCacheEnvelope[] } { + return { + entries, + async pullAll() { return entries.map((entry) => ({ ...entry })); }, + async push(entry) { entries.push(entry); }, + async delete() {}, + }; +} + From 13ce5ea50a0e231819eb6f33ae76db1296ac06d2 Mon Sep 17 00:00:00 2001 From: Libby Date: Thu, 12 Nov 2026 05:23:25 +0000 Subject: [PATCH 49/93] Python: a payload is one MessagePack value (bytes-after-py) (#129) --- packages/cultcache-py/CHANGELOG.md | 6 +- packages/cultcache-py/README.md | 1 + packages/cultcache-py/pyproject.toml | 2 +- .../cultcache-py/src/cultcache_py/__init__.py | 2 + .../src/cultcache_py/documents.py | 26 ++++- .../cultcache-py/tests/test_union_parity.py | 72 ++++++++++++++ packages/cultmesh-py/pyproject.toml | 2 +- packages/cultnet-py/CHANGELOG.md | 1 + packages/cultnet-py/pyproject.toml | 2 +- .../cultnet-py/src/cultnet_py/messages.py | 3 +- .../cultnet-py/src/cultnet_py/replication.py | 6 +- packages/cultnet-py/tests/test_cultnet.py | 45 +++++++++ .../tests/test_declared_dependency_ranges.py | 94 +++++++++++++++++++ 13 files changed, 251 insertions(+), 11 deletions(-) create mode 100644 packages/cultnet-py/tests/test_declared_dependency_ranges.py diff --git a/packages/cultcache-py/CHANGELOG.md b/packages/cultcache-py/CHANGELOG.md index 08fb4d1f..ea3f72fd 100644 --- a/packages/cultcache-py/CHANGELOG.md +++ b/packages/cultcache-py/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Added + +- `read_payload_tree(type_name, payload, decoder=None)`: the exported owner of the one-value, depth-first payload read that `decode_payload` uses, for payloads read outside a definition (`cultnet-py`'s witness bundle decode needs it, so `cultnet-py` now requires `cultcache-py>=0.4.0`). The manifest version is 0.4.0, the minor these Unreleased breaking changes imply, set in this change so the workspace install resolves the new bound; the `## Unreleased` heading becomes `## [0.4.0]` when it is tagged. + ### Changed - `msgpack` is a hard dependency, as `pyproject.toml` declared it. The optional-import fallbacks and the `cultcache-py[msgpack]` extra they named (which never existed) are gone: a missing msgpack is an `ImportError` at import. @@ -10,7 +14,7 @@ ### Breaking - A record whose payload carries a slot past its reader's declared slots, at any depth (the document, a nested record, a list element, a union arm, a unit arm), is read, held read-only, and every write or removal of its key (`put`, `put_global`, `update`, `put_envelope`, `put_envelopes`, `delete`, `delete_global`) is refused with `SchemaConflictError` before any store is touched (`cultlib-gaps:ruling:extra-trailing-slots`). Reading dropped those slots silently and the next write shed them. `SchemaConflictError.unread_slots` is an `UnreadSlots(type_name, slot_count, declared_count, occurrences, depth0_only)` and the message names the type and both counts, never a slot value. A depth-0 mark is consented, and the record writable, only when the record is stored under an id the document lists in `compatible_schema_ids` and that id's catalog entry has slots past the document's, covering the mark's slot count (`cultlib-gaps:ruling:slot-mark-consent`); a nested mark, or a mark under the document's own id, keeps the record read-only whatever is declared. Missing trailing slots keep their declared defaults. An untouched marked record keeps its stored bytes through every other write. Added `CultCache.is_writable(document, key)` (advisory; the write still decides: it also answers False for a foreign-carried key, for a key held as a type the document does not own, and for a global document at a non-global key; `delete` asks the same judges, so removing a key held as another type is refused with `SchemaConflictError` unless the document owns that record's schema id (its own or one it lists compatible), in which case the removal removes that record, its file row and its lookups, as a write of the key would replace it; it returned silently and removed nothing), `DocumentDefinition.decode_payload_marked`, and `UnreadSlots`. A document with a custom `decode` (no slot codec) never marks. A store holding a variant record is still unreadable here. Implies a minor bump while the version is 0.x (0.3.0 to 0.4.0): code that wrote back a record from a newer writer now gets `SchemaConflictError`. - +- A payload is one MessagePack value: `DocumentDefinition.decode_payload` refuses bytes after it, by count, with `ValueError` ` payload has N bytes after its first MessagePack value (refused on read).` (never the bytes, never the depth refusal; a payload that ends early stays the decoder's), as the C# reference does (`tests/vectors/union-parity/bytes-after-outcomes.msgpack`). It raised `msgpack.ExtraData` (a `ValueError`, so only a caller naming `msgpack.ExtraData` is affected); this is a minor-version bump while pre-1.0. The refused set is unchanged. Cache loads, `get` and every definition built on the MessagePack decoder go through it; a user `payload_decoder` that raises `ExtraData` is refused the same way. Depth is judged first, so a value nested past 256 with a tail is the depth refusal, as in C#. - A cache holds one record per key whatever its type (`variants:ruling:cache-refuses-retype`). A `put`, `put_envelope`, `put_envelopes` or `put_global` of a second type under a key the cache holds, or under a key a store carries foreign, is refused with `SchemaConflictError` naming the key, even with no store, and changes nothing; retype a key by deleting it, then putting the new type. A write may replace another registered type's record only when the written type owns its schema id (declares it or lists it compatible) and both types share one home store. A load that reads one key from two stores, whatever the types, is refused with `CultCacheError` naming the key and both types, and admits nothing. A store that holds a record no registered type claims now loads and is carried: the cache does not serve it, the store keeps its bytes through every write, `delete` or `put` at its key is refused with `SchemaConflictError`, and registering a type that declares its schema id claims it at the next load. It raised `CultCacheError` "Unknown persisted document type". Two global types share the key `__global__`, so a second global type in one cache is now refused, and a store that already holds two global types at `__global__` (a JSONL store written by caches that never pulled could produce one) no longer loads: the pull raises `CultCacheError` and admits nothing. C# refuses a second persisted global on pull the same way (`cultcache-store-composition.md`, "Globals never invented"); repair such a file by deleting one of the two records with the runtime that wrote it. - Added `foreign_records()` and the frozen `ForeignRecord(key, schema_id, schema_name, stored_at)`, sorted by key in UTF-16 code-unit order. `stores.retyped` and `stores.written_owns` lose their underscore: the cache judge and the single-file store's commit guard ask one predicate. diff --git a/packages/cultcache-py/README.md b/packages/cultcache-py/README.md index 72d51562..4a7236fd 100644 --- a/packages/cultcache-py/README.md +++ b/packages/cultcache-py/README.md @@ -19,6 +19,7 @@ discovery, sessions, local server) build on it, mirroring `cultcache-rs` / `[format, schemaCatalog, records]` - records store raw MessagePack payload bytes under schema-catalog identity - payloads decode only through the registered document definition for that type +- a payload is exactly one MessagePack value: bytes after it are refused on read, by count - a cache holds one record per key whatever its type: a write of a second type at a held key is refused with `SchemaConflictError` (in memory too), and two stores holding one key refuse the load - a store record no registered type claims loads and is carried as foreign: `cache.foreign_records()` lists it, the store diff --git a/packages/cultcache-py/pyproject.toml b/packages/cultcache-py/pyproject.toml index 6c46d102..15b724ba 100644 --- a/packages/cultcache-py/pyproject.toml +++ b/packages/cultcache-py/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "cultcache-py" -version = "0.3.0" +version = "0.4.0" description = "Python CultCache runtime for GameCult typed state." readme = "README.md" requires-python = ">=3.10" diff --git a/packages/cultcache-py/src/cultcache_py/__init__.py b/packages/cultcache-py/src/cultcache_py/__init__.py index 4873b6ed..69828bf3 100644 --- a/packages/cultcache-py/src/cultcache_py/__init__.py +++ b/packages/cultcache-py/src/cultcache_py/__init__.py @@ -16,6 +16,7 @@ from .documents import ( define_document_type, define_record_codec, define_union, + read_payload_tree, ) from .leaves import SlotCodec from .stores import JsonLinesBackingStore, SchemaConflictError, SingleFileMessagePackBackingStore, StoreUnreadableError @@ -40,6 +41,7 @@ __all__ = [ "StoreUnreadableError", "UnionArm", "UnreadSlots", + "read_payload_tree", "leaves", "database_entry_field", "define_database_entry_type", diff --git a/packages/cultcache-py/src/cultcache_py/documents.py b/packages/cultcache-py/src/cultcache_py/documents.py index 4f7dfe7d..ff9039dc 100644 --- a/packages/cultcache-py/src/cultcache_py/documents.py +++ b/packages/cultcache-py/src/cultcache_py/documents.py @@ -158,6 +158,26 @@ def _messagepack_payload_decoder(payload: bytes) -> Any: return msgpack.unpackb(payload, raw=False) +def read_payload_tree(type_name: str, payload: bytes, decoder: Callable[[bytes], Any] | None = None) -> Any: + """The one read of a payload's tree: exactly one MessagePack value, within the nesting limit. Depth is judged first, + on the value read so far, so a tail never pre-empts it (C# throws at container 257 before it reaches the tail); then a + tail is refused by count and never echoed. Raised outside the handler so no `__context__` keeps the tail.""" + after = -1 + unpacked: Any = None + try: + tree = (decoder or _messagepack_payload_decoder)(payload) + except _STACK_FAULTS: + raise CultCachePayloadDepthError(type_name, "read") from None + except msgpack.ExtraData as error: + after = len(error.extra) + unpacked = error.unpacked + if after >= 0: + _refuse_past_depth_limit(unpacked, type_name, "read") + raise ValueError(f"{type_name} payload has {after} bytes after its first MessagePack value (refused on read).") + _refuse_past_depth_limit(tree, type_name, "read") + return tree + + @dataclass(frozen=True) class DatabaseEntryField: name: str @@ -234,11 +254,7 @@ class DocumentDefinition(Generic[T]): def decode_payload_marked(self, payload: bytes) -> tuple[T, UnreadSlots | None]: """The value and what the decode found past the declared slots, at any depth. The payload depth check runs first: a payload past the limit is refused, never marked.""" - try: - tree = self.payload_decoder(payload) - except _STACK_FAULTS: - raise CultCachePayloadDepthError(self.type, "read") from None - _refuse_past_depth_limit(tree, self.type, "read") + tree = read_payload_tree(self.type, payload, self.payload_decoder) outer, opened = _current_scope(), _SlotScope() _scope.open = opened try: diff --git a/packages/cultcache-py/tests/test_union_parity.py b/packages/cultcache-py/tests/test_union_parity.py index 1e19ce8d..793933ff 100644 --- a/packages/cultcache-py/tests/test_union_parity.py +++ b/packages/cultcache-py/tests/test_union_parity.py @@ -661,6 +661,78 @@ def _as_tuple(tree: object) -> object: return tuple(_as_tuple(child) for child in tree) if isinstance(tree, list) else tree + +class BytesAfterVectorTests(unittest.TestCase): + CLAUSE = "bytes after its first MessagePack value (refused on read)" + + def _assert_chain_clean(self, error: BaseException, file: str) -> None: + link: BaseException | None = error + while link is not None: + self.assertNotIn(CANARY, str(link), file) + self.assertNotIsInstance(link, msgpack.ExtraData, file) + link = link.__cause__ or link.__context__ + + def test_every_bytes_after_vector_is_refused(self) -> None: + base = _vector("union-surface.msgpack") + entries = msgpack.unpackb(_vector("bytes-after-outcomes.msgpack"), raw=False) + self.assertEqual(len(entries), 4) + for file, outcome_kind, union, key, detail in entries: + self.assertEqual((outcome_kind, union, key, detail), ("refused", "UnionSurface", None, "bytes-after"), file) + payload = _vector(file) + self.assertTrue(payload.startswith(base), file) + count = len(payload) - len(base) + with self.assertRaises(ValueError, msg=file) as caught: + surface_document.decode_payload(payload) + error = caught.exception + self.assertIs(type(error), ValueError, file) + self.assertNotIsInstance(error, CultCachePayloadDepthError, file) + self.assertEqual( + str(error), f"{surface_document.type} payload has {count} {self.CLAUSE}.", file + ) + self.assertNotIn("the limit is", str(error), file) + self._assert_chain_clean(error, file) + + def test_a_single_file_store_holding_a_tail_as_a_record_payload_refuses_on_load(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "tail.cc" + shutil.copyfile(VECTORS / "union-surface.cc", path) + store = SingleFileMessagePackBackingStore(path) + [envelope] = store.pull_all() + store.push(dataclasses.replace(envelope, payload=_vector("bytes-after-nil.msgpack"))) + cache = ( + CultCache.builder() + .register_document_type(surface_document) + .add_generic_store(SingleFileMessagePackBackingStore(path)) + .build() + ) + with self.assertRaises(ValueError) as caught: + cache.pull_all_backing_stores() + self.assertIn(f"1 {self.CLAUSE}", str(caught.exception)) + self.assertIn(surface_document.type, str(caught.exception)) + self.assertIsNone(cache.get(surface_document, "surface")) + + def test_depth_wins_over_a_tail_whatever_the_unpackers_own_stack_limit(self) -> None: + # Vector-independent: a value nested past 256 is the depth refusal even with a tail, as C# throws at container 257 + # before it reaches the tail; 2000 is past msgpack's own stack, so the winner is not a library constant. + for depth in (257, 300, 900, 2000, 100000): + for tail in (b"\xc0", b"\xc1\xc1"): + with self.subTest(depth=depth, tail=len(tail)): + with self.assertRaises(CultCachePayloadDepthError) as caught: + surface_document.decode_payload(b"\x91" * depth + b"\xc0" + tail) + self.assertIn("refused on read", str(caught.exception)) + for depth in (1, 255, 256): + with self.subTest(at=depth): + with self.assertRaises(ValueError) as caught: + surface_document.decode_payload(b"\x91" * depth + b"\xc0\xc0") + self.assertNotIsInstance(caught.exception, CultCachePayloadDepthError) + self.assertIn(f"1 {self.CLAUSE}", str(caught.exception)) + + def test_a_payload_that_ends_early_is_the_decoders_not_bytes_after(self) -> None: + truncated = _vector("union-surface.msgpack")[:-1] + with self.assertRaises(Exception) as caught: + surface_document.decode_payload(truncated) + self.assertNotIn("bytes after", str(caught.exception)) + class PayloadDepthRuleTests(unittest.TestCase): """The nesting rule at its seam, on definitions that are not unions: the same routine serves any payload codec.""" diff --git a/packages/cultmesh-py/pyproject.toml b/packages/cultmesh-py/pyproject.toml index 5f9bda4d..18f23060 100644 --- a/packages/cultmesh-py/pyproject.toml +++ b/packages/cultmesh-py/pyproject.toml @@ -16,7 +16,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", ] -dependencies = ["cultcache-py>=0.2.0", "cultnet-py>=0.1.0", "cryptography>=41"] +dependencies = ["cultcache-py>=0.4.0", "cultnet-py>=0.1.0", "cryptography>=41"] [project.scripts] cultmesh-py-daemon = "cultmesh_py.daemon:main" cultmesh-py-verify = "cultmesh_py.verify:main" diff --git a/packages/cultnet-py/CHANGELOG.md b/packages/cultnet-py/CHANGELOG.md index b8a3773e..525bd4bd 100644 --- a/packages/cultnet-py/CHANGELOG.md +++ b/packages/cultnet-py/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased (breaking; version 0.x, so the next minor) +- `decode_witness_artifact_bundle_payload` (and `CultNetWitnessArtifactBundle.from_payload`) read the payload through `cultcache_py.read_payload_tree`: bytes after its first MessagePack value raise `ValueError` `cultnet.witness_artifact_bundle payload has N bytes after its first MessagePack value (refused on read).` instead of `msgpack.ExtraData`, and a payload nested past 256 raises `CultCachePayloadDepthError`. Needs `cultcache-py` 0.4.0, the release that exports `read_payload_tree`: the dependency is now `cultcache-py>=0.4.0` (it was `>=0.2.0`, which admits releases whose `import cultnet_py` fails), and `tests/test_declared_dependency_ranges.py` fails when a declared bound admits a tagged release lacking a name the package imports. The same check found `cultmesh-py` importing `cache.index_text`, which `cultcache-py` 0.2.0 lacks; its bound is `cultcache-py>=0.4.0` too (it has no changelog of its own). A raw record with bytes after its first value gives no schema hint to `resolve_document_for_raw_record`, so an unknown schema id stays `KeyError` rather than being routed; a registered one is refused by count when applied. - `apply_raw_document_record` and `apply_raw_snapshot` raise `SchemaConflictError` when a remote record is of another type than the one the cache holds at its key (`cultcache-py` holds one record per key whatever its type); the cache is unchanged, and the caller decides whether to drop the record or the peer. `cultmesh-py`'s server answers such a put with `message_invalid`; its client's `read_next_change` lets the error out. - `CultNetDatabaseChange` carries `commit_id` and `commit_count` (`commitId`/`commitCount`; None outside a multi-record commit, as C# writes nil): `from_wire` refuses an empty or non-string id and a count that is not an integer of at least 2, `to_wire` writes them when set, and the published `database_change_raw` schema lists both. Python applies no batch. The `simulation_fact` document's `shard`, `frame`, `subject` and `claim_kind` aliases are `roles` (many facts share each); the catalog bytes do not move. - Decodes the write receipt frames of `contracts/cultnet/interop/write-receipt.md`: `parse_message` validates `cultnet.write_admitted.v0` and `cultnet.error.v1` against their closed schemas (unknown keys, empty or non-string ids, an unknown operation or code are refused; `error.v1` requires `messageId`, string or null; `limit_reached` is `error.v1` only). Added `CULTNET_ERROR_V1_CODES`, `CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS`, `write_content_hash` and `hello_advertises_write_receipts`; `hello` takes `supported_message_versions`. `tests/test_write_receipt.py` replays the vectors' `contentHash`, `wire` and `schemaInvalid` sections. Not implemented here: the client write tracker (`scenarios`) and the server rules (`dedupe`, `blankFields`); this package has no receipt server and no write client. diff --git a/packages/cultnet-py/pyproject.toml b/packages/cultnet-py/pyproject.toml index 41c4e8f6..09db2733 100644 --- a/packages/cultnet-py/pyproject.toml +++ b/packages/cultnet-py/pyproject.toml @@ -16,7 +16,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", ] -dependencies = ["cultcache-py>=0.2.0", "msgpack>=1.0"] +dependencies = ["cultcache-py>=0.4.0", "msgpack>=1.0"] [project.optional-dependencies] crypto = ["cryptography>=42"] diff --git a/packages/cultnet-py/src/cultnet_py/messages.py b/packages/cultnet-py/src/cultnet_py/messages.py index d5e26b7a..c9b7d4a6 100644 --- a/packages/cultnet-py/src/cultnet_py/messages.py +++ b/packages/cultnet-py/src/cultnet_py/messages.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from typing import Any import msgpack +from cultcache_py import read_payload_tree from .unicode_messageid_tables import DEFAULT_IGNORABLE, LETTER_OR_NUMBER @@ -544,7 +545,7 @@ def encode_witness_artifact_bundle_payload(bundle: dict[str, Any] | CultNetWitne def decode_witness_artifact_bundle_payload(payload: bytes) -> dict[str, Any]: - slots = msgpack.unpackb(payload, raw=False) + slots = read_payload_tree("cultnet.witness_artifact_bundle", payload) if not isinstance(slots, list) or len(slots) < 8: raise ValueError("CultNet witness artifact bundle payload must be an 8-slot MessagePack array") return { diff --git a/packages/cultnet-py/src/cultnet_py/replication.py b/packages/cultnet-py/src/cultnet_py/replication.py index 531c0012..28f5da33 100644 --- a/packages/cultnet-py/src/cultnet_py/replication.py +++ b/packages/cultnet-py/src/cultnet_py/replication.py @@ -171,9 +171,13 @@ def resolve_document_for_raw_record( def _infer_schema_version_from_payload(payload: bytes) -> str | None: + import msgpack # type: ignore try: - import msgpack # type: ignore decoded = msgpack.unpackb(payload, raw=False) + except msgpack.ExtraData: + # A payload with bytes after its value gives no routing hint: the record is unroutable by inference, and a + # registered schema id routes it to a definition whose decode refuses the tail by count. + return None except Exception: return None if isinstance(decoded, list) and decoded and isinstance(decoded[0], str): diff --git a/packages/cultnet-py/tests/test_cultnet.py b/packages/cultnet-py/tests/test_cultnet.py index 48972221..cdfaee5b 100644 --- a/packages/cultnet-py/tests/test_cultnet.py +++ b/packages/cultnet-py/tests/test_cultnet.py @@ -1971,6 +1971,34 @@ class CultNetTests(unittest.TestCase): apply_raw_document_record(cache, schema_document_map([first, second]), raw(first)) # the same type still replaces self.assertEqual(cache.get_required(first, "shared").name, "remote") + def test_a_raw_record_with_a_tail_is_unroutable_by_inference_and_refused_by_count_when_routed(self) -> None: + import msgpack # type: ignore + from cultcache_py import define_document_type + from cultnet_py import resolve_document_for_raw_record + + document = define_document_type("tests.tail_note", schema_id="tests.tail_note", schema_version="tests.tail_note.v1", + payload_encoder=msgpack.packb, payload_decoder=lambda payload: msgpack.unpackb(payload, raw=False)) + known = document.catalog_entry().schema_id + head = msgpack.packb(["tests.tail_note.v1", "x"], use_bin_type=True) + + def raw(schema_id: str, payload: bytes) -> dict: + return {"schemaId": schema_id, "recordKey": "k", "storedAt": "2026-06-13T00:00:00Z", + "payloadEncoding": "messagepack", "payload": payload} + + documents = schema_document_map([document]) + # Routing by inference reads a hint, not a payload: a payload with a tail gives no hint, so an unknown schema id + # stays the unknown-schema error (KeyError) even when the first value names a registered schema. + self.assertIs(resolve_document_for_raw_record(documents, "unknown", raw("unknown", head)), document) + with self.assertRaises(KeyError): + resolve_document_for_raw_record(documents, "unknown", raw("unknown", head + b"\xc0")) + # A registered schema id routes, and the tail is refused by count when the record is applied. + cache = CultCache() + cache.register_document_type(document) + with self.assertRaises(ValueError) as caught: + apply_raw_document_record(cache, documents, raw(known, head + b"\xc0")) + self.assertIn("1 bytes after its first MessagePack value", str(caught.exception)) + self.assertIsNone(cache.get(document, "k")) + def test_cultnet_replication_helpers_apply_raw_snapshot_and_shard_log(self) -> None: document = define_database_entry_type( "replica.item", @@ -2661,6 +2689,23 @@ class CultNetTests(unittest.TestCase): self.assertEqual(responses[0]["details"]["value"], schema_version) self.assertNotIn("messageId", responses[0]) + def test_witness_artifact_bundle_payload_is_one_value_and_depth_wins_over_a_tail(self) -> None: + import msgpack # type: ignore + from cultcache_py import CultCachePayloadDepthError + + payload = msgpack.packb(["b", "k", "t", {}, [], [], [], {}], use_bin_type=True) + decode_witness_artifact_bundle_payload(payload) + for tail, count in ((b"\xc0", 1), (b"\xc1\xc1", 2)): + with self.assertRaises(ValueError) as caught: + decode_witness_artifact_bundle_payload(payload + tail) + self.assertIs(type(caught.exception), ValueError) + self.assertEqual( + str(caught.exception), + f"cultnet.witness_artifact_bundle payload has {count} bytes after its first MessagePack value (refused on read).", + ) + with self.assertRaises(CultCachePayloadDepthError): + decode_witness_artifact_bundle_payload(b"\x91" * 300 + b"\xc0\xc0") + def test_cultnet_simulation_consensus_dedupes_witnesses_and_requires_quorum(self) -> None: from cultnet_py import CultNetSimulationConsensus diff --git a/packages/cultnet-py/tests/test_declared_dependency_ranges.py b/packages/cultnet-py/tests/test_declared_dependency_ranges.py new file mode 100644 index 00000000..9baf15fc --- /dev/null +++ b/packages/cultnet-py/tests/test_declared_dependency_ranges.py @@ -0,0 +1,94 @@ +"""A sibling package resolves through the workspace's editable install, so a package that starts importing a new name from +cultcache-py passes whatever range it declares. A consumer outside the workspace installs the newest release the range +admits. This is the Python form of scripts/test-declared-dependency-ranges.mjs: for every tagged cultcache-py release +(`cultcache-py-vX.Y.Z`) that the `cultcache-py>=` bound of cultnet-py or cultmesh-py admits, every name those packages +import from cultcache_py must be defined by that release's source. It needs git with the release tags and skips without +them. Only `>=` bounds are read; star imports and names reached by attribute access are not checked.""" + +from __future__ import annotations + +import ast +import re +import subprocess +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] +PACKAGES = ROOT / "packages" +CONSUMERS = ("cultnet-py", "cultmesh-py") + + +def _version(text: str) -> tuple[int, ...]: + return tuple(int(part) for part in text.split(".")) + + +def _git(*args: str) -> str | None: + try: + done = subprocess.run(["git", *args], cwd=ROOT, capture_output=True, text=True, check=True) + except (OSError, subprocess.CalledProcessError): + return None + return done.stdout + + +def _lower_bound(package: str) -> tuple[int, ...]: + manifest = (PACKAGES / package / "pyproject.toml").read_text(encoding="utf-8") + match = re.search(r'"cultcache-py>=(\d+\.\d+\.\d+)"', manifest) + assert match, f"{package} declares no cultcache-py>= bound" + return _version(match.group(1)) + + +def _imports(package: str) -> dict[str, set[str]]: + """module -> names imported from it, for every `from cultcache_py[.x] import names` under the package's src.""" + found: dict[str, set[str]] = {} + for source in (PACKAGES / package / "src").rglob("*.py"): + for node in ast.walk(ast.parse(source.read_text(encoding="utf-8-sig"))): + if isinstance(node, ast.ImportFrom) and node.level == 0 and node.module: + if node.module == "cultcache_py" or node.module.startswith("cultcache_py."): + found.setdefault(node.module, set()).update(alias.name for alias in node.names if alias.name != "*") + return found + + +def _defined(tag: str, module: str) -> set[str] | None: + base = "packages/cultcache-py/src/" + module.replace(".", "/") + for path in (base + ".py", base + "/__init__.py"): + text = _git("show", f"{tag}:{path}") + if text is None: + continue + names: set[str] = set() + for node in ast.parse(text.lstrip("\ufeff")).body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + names.add(node.name) + elif isinstance(node, (ast.Import, ast.ImportFrom)): + names.update((alias.asname or alias.name).split(".")[0] for alias in node.names) + elif isinstance(node, (ast.Assign, ast.AnnAssign)): + for target in node.targets if isinstance(node, ast.Assign) else [node.target]: + if isinstance(target, ast.Name): + names.add(target.id) + return names + return None + + +class DeclaredDependencyRangeTests(unittest.TestCase): + def test_every_admitted_cultcache_release_defines_what_the_consumers_import(self) -> None: + listed = _git("tag", "--list", "cultcache-py-v*") + tags = sorted((t for t in (listed or "").split() if re.fullmatch(r"cultcache-py-v\d+\.\d+\.\d+", t)), + key=lambda t: _version(t.rsplit("v", 1)[1])) + if not tags: + self.skipTest("git with the cultcache-py release tags is not available") + current = re.search(r'^version = "(\d+\.\d+\.\d+)"', (PACKAGES / "cultcache-py" / "pyproject.toml").read_text(encoding="utf-8"), re.M) + assert current + for package in CONSUMERS: + bound = _lower_bound(package) + self.assertLessEqual(bound, _version(current.group(1)), f"{package}'s bound is above the workspace cultcache-py") + imports = _imports(package) + for tag in tags: + if _version(tag.rsplit("v", 1)[1]) < bound: + continue + for module, names in imports.items(): + defined = _defined(tag, module) + self.assertIsNotNone(defined, f"{package} imports {module}, which {tag} does not have") + self.assertEqual(sorted(names - defined), [], f"{package} imports names {tag} lacks from {module}") + + +if __name__ == "__main__": + unittest.main() From 363a8d6fe5b0e09330fe5a2f26bcdb80238e5dd5 Mon Sep 17 00:00:00 2001 From: Libby Date: Thu, 12 Nov 2026 05:43:55 +0000 Subject: [PATCH 50/93] Rust: a payload is one MessagePack value (bytes-after-rs) (#131) --- docs/cultcache-union-parity-map.md | 2 +- packages/cultcache-rs/CHANGELOG.md | 2 + packages/cultcache-rs/src/lib.rs | 137 +++++++++++++--- packages/cultcache-rs/tests/slot_readonly.rs | 52 +++++- packages/cultcache-rs/tests/union_parity.rs | 162 ++++++++++++++++++- packages/cultnet-rs/CHANGELOG.md | 1 + packages/cultnet-rs/tests/cultnet.rs | 10 +- packages/cultnet-rs/tests/decode_door.rs | 10 ++ 8 files changed, 346 insertions(+), 30 deletions(-) diff --git a/docs/cultcache-union-parity-map.md b/docs/cultcache-union-parity-map.md index 368be16a..1442abdc 100644 --- a/docs/cultcache-union-parity-map.md +++ b/docs/cultcache-union-parity-map.md @@ -259,7 +259,7 @@ landed in `ead5260c`, Python in `5618d8dd`, Rust in `c028c511`. TypeScript is no | Runtime | Owner of the decision (constant and walker) | Doors routed through it | |---|---|---| | C# | `CultPayloadDepth.Check` (`src/GameCult.Caching.MessagePack/CultPayloadDepth.cs`), limit `CultMessagePackSecurity.MaximumPayloadDepth`. MessagePack's own graph counter is set to the same value as a backstop only. | Write: `CultDocumentMessagePackSerialization.SerializeUntyped`. Read: `DeserializeUntyped`. The cache store (`SerializePayload`/`DeserializePayload`), the directory store, `CultMesh` and single-file documents all call those two; C# has no separate raw-envelope or prepared-batch door. | -| Rust | `cultcache_rs::check_payload_depth` and `MAX_PAYLOAD_DEPTH` (`packages/cultcache-rs/src/lib.rs`). | Read: `decode_payload` (get, list, `validate_envelope`/`put_envelope`, `finish_prepared_entry`). Write: `put`/`prepare_entry`/`prepare_entry_named` reach it through `finish_prepared_entry`, which decodes what it just encoded. Raw envelope: `put_raw_envelope`, the door CultMesh network puts use (`cultmesh-rs` `lib.rs`, two call sites). Prepared batch: `put_prepared_batch` via `require_payload_depth`. cultnet-rs: every free-form or skippable wire payload decodes through `cultcache_rs::decode_payload` (message decode in `contracts.rs`, `provider_session`, `media_stream_wire`, `replication`, `service_identity`). | +| Rust | `check_record_payload` (private; depth, then no bytes after the first value) over `first_value_end`, and `MAX_PAYLOAD_DEPTH`; `cultcache_rs::check_payload_depth` is the depth-only probe for outside callers (`packages/cultcache-rs/src/lib.rs`). | Read: `decode_payload` (get, list, `validate_envelope`/`put_envelope`, `finish_prepared_entry`). Write: `put`/`prepare_entry`/`prepare_entry_named` reach it through `finish_prepared_entry`, which decodes what it just encoded. Raw envelope: `put_raw_envelope`, the door CultMesh network puts use (`cultmesh-rs` `lib.rs`, two call sites). Prepared batch: `put_prepared_batch` via `require_record_payload`. Load: `pull_all_backing_stores` and `load_soa` run `require_record_payload` on every record, so a store holding a payload past the bound (or with bytes after it) is refused at load, and the cache then refuses every write and removal (`CultCacheLoadRefused`) until a load succeeds. cultnet-rs: every free-form or skippable wire payload decodes through `cultcache_rs::decode_payload` (message decode in `contracts.rs`, `provider_session`, `media_stream_wire`, `replication`, `service_identity`). | | Python | `_refuse_past_depth_limit` and `MAX_PAYLOAD_DEPTH` (`packages/cultcache-py/src/cultcache_py/documents.py`); a msgpack `StackError` on read maps to the same error. | `DocumentDefinition.encode_payload` (write) and `decode_payload` (read). `CultCache.put`, `put_envelope`, `put_envelopes` and load-time hydration go through them; there is no raw-envelope door. cultmesh-py round trips call the same pair. | - **Doors deliberately not routed.** cultnet-rs `idunn_lifecycle_brake`, `provider_health_contracts` and diff --git a/packages/cultcache-rs/CHANGELOG.md b/packages/cultcache-rs/CHANGELOG.md index 353facea..4e97588c 100644 --- a/packages/cultcache-rs/CHANGELOG.md +++ b/packages/cultcache-rs/CHANGELOG.md @@ -15,6 +15,8 @@ Semver: this is a breaking change in the 0.x line (0.3.x to 0.4.0). - `SchemaConflictError` gains the public field `unread_slots: Option`; code that builds it with a struct literal must set it (`None`). - A unit arm with slots (`[N, [extra...]]`) decodes as the unit arm and marks the record, where the derive refused it. - The registration map's value changes from the schema name to a private struct holding the schema name and the unread-slots check. +- A cache whose last `pull_all_backing_stores` was refused (a tail, a payload past the nesting bound, a duplicate unique-index value, a retype conflict, a record from the wrong store) refuses every `put`, `put_envelope`, `put_raw_envelope`, `put_prepared_batch`, `update` and `delete` with `CultCacheLoadRefused` until a pull succeeds. Before, it stayed writable over an empty image, and the next batch write replaced the store with only the new records. +- `decode_payload`, `put_raw_envelope` and prepared batches refuse a payload with bytes after its first MessagePack value (`payload has {n} bytes after its first MessagePack value`), by count; the tail is never walked or quoted. They used to read the first value and keep or drop the tail, so a record with a stray tail was served and rewritten without it. A store holding such a record (single-file, redb, `load_soa`) is refused at load, naming the type, the key and the count, and stays unreadable until the record is repaired; the same check now runs on every record at load, as the C# and Python runtimes do at open. The nesting bound is part of that check, so a stored payload nested past it is refused at load too, where it used to load and refuse on read. - A single-file store written as `cultcache.store.v3` is read and written: a store the C# runtime marked v3 (it holds a record with an element id: an owned list element with a `[CultElementId]` member) keeps its header here, and is carried through every rewrite with its ids; this runtime never drops the mark. A runtime from before this change refuses a v3 store by name. Rolling back to an older release after a store has been written as v3 is one-way: there is no carry-back, so restore from a copy taken before upgrading. - A write over a file the reader refuses (a zero-byte file, a truncated store, a stamp this runtime does not read) refuses with `CultCacheStoreUnreadable` and leaves the file as it is. A whole-store write used to replace such a file. This reverses 0.3.1, where `push_all` replaced a file with no readable stamp (healing it); the stamp rule now has one owner, the reader every write asks under the lock, and it accepts v1 and v3 (0.3.1 refused v3). - `#[cultcache(index)]` means unique, as in C# `RefuseIndexSharing`: within one type and one alias no two records hold one non-blank value. A put, batch, push or load that would make two records hold a value is refused, naming both keys and the alias, and a store that already holds a duplicate does not open. A grouping index is no longer expressible; a consumer that relied on shared values must drop the `index` and look up by another way. diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index 59ffa1af..522e226e 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -600,14 +600,22 @@ impl std::error::Error for PayloadTooDeep {} /// Walks the MessagePack headers of one payload without recursion, counting every array and map and /// refusing the first container past [`MAX_PAYLOAD_DEPTH`]. Scalars are skipped by length and nothing -/// is allocated per element. A payload that ends early is the decoder's to refuse. +/// is allocated per element. Only the first value is walked; a payload that ends early is the +/// decoder's to refuse. A depth-only probe: [`decode_payload`] also refuses bytes after the value. pub fn check_payload_depth(bytes: &[u8]) -> std::result::Result<(), PayloadTooDeep> { + first_value_end(bytes).map(|_| ()) +} + +/// Where the first MessagePack value of `bytes` ends, walking only that value (its tail is never +/// read). `None` when the payload ends inside a header; a final scalar cut short ends past the +/// payload's length, which is not bytes after the value either. Both are the decoder's to refuse. +fn first_value_end(bytes: &[u8]) -> std::result::Result, PayloadTooDeep> { let mut remaining = [0usize; MAX_PAYLOAD_DEPTH]; let mut depth = 0usize; let mut position = 0usize; while position < bytes.len() { let Some((advance, children)) = element_header(bytes, position) else { - return Ok(()); + return Ok(None); }; position = position.saturating_add(advance); if let Some(children) = children { @@ -629,7 +637,18 @@ pub fn check_payload_depth(bytes: &[u8]) -> std::result::Result<(), PayloadTooDe depth -= 1; } if depth == 0 { - return Ok(()); + return Ok(Some(position)); + } + } + Ok(None) +} + +/// The one rule for "is this payload one value": the nesting bound, then no bytes after the first +/// value. The count is all it reports; the tail is never walked or quoted. +fn check_record_payload(bytes: &[u8]) -> Result<()> { + if let Some(end) = first_value_end(bytes)? { + if end < bytes.len() { + bail!("payload has {} bytes after its first MessagePack value", bytes.len() - end); } } Ok(()) @@ -784,7 +803,7 @@ fn decode_grown(bytes: &[u8]) -> std::result::Re grown(&mut reader, |grown| T::deserialize(grown)) } -/// The one decode of a document payload: the nesting bound first, then [`decode_grown`], whose +/// The one decode of a document payload: the payload check first (nesting bound, one value), then [`decode_grown`], whose /// rmp-serde depth counter (1024) is only a backstop. A failure keeps its kind and never quotes the /// bytes. Readers of stored envelope payloads outside this crate decode through it too. /// @@ -796,7 +815,7 @@ fn decode_grown(bytes: &[u8]) -> std::result::Re /// verified on x86_64-pc-windows-msvc by hand (`decode_stack`, revision 10a39dac, 13 tests, debug and release); a /// `stacker` bump or a change to the stack-size tests repeats it. pub fn decode_payload(payload: &[u8]) -> Result { - check_payload_depth(payload)?; + check_record_payload(payload)?; decode_grown(payload).map_err(opaque_decode_error) } @@ -918,10 +937,11 @@ impl Drop for UnreadScope { } } -/// [`decode_payload`] with the mark: the same depth check and grown decode, inside a scope the derives' slot -/// visitors note into. A payload past the nesting limit is refused before the decode, never marked. +/// [`decode_payload`] with the mark: the same payload check and grown decode, inside a scope the derives' slot +/// visitors note into. A payload past the nesting limit or with bytes after its value is refused before the +/// decode, never marked. pub fn decode_payload_marked(payload: &[u8]) -> Result<(T, Option)> { - check_payload_depth(payload)?; + check_record_payload(payload)?; let scope = UnreadScope::open(); let value = decode_grown(payload).map_err(opaque_decode_error)?; Ok((value, scope.mark())) @@ -948,10 +968,10 @@ struct Definition { unread_slots: fn(&[u8]) -> Result>, } -/// The nesting bound for an envelope admitted without a typed decode, from the same walk that -/// [`decode_payload`] runs first. Names the entry's type and key, never the payload. -fn require_payload_depth(entry: &CultCacheEnvelope) -> Result<()> { - check_payload_depth(&entry.payload).with_context(|| { +/// The payload check for an envelope admitted without a typed decode, the one [`decode_payload`] +/// runs first. Names the entry's type and key, never the payload. +fn require_record_payload(entry: &CultCacheEnvelope) -> Result<()> { + check_record_payload(&entry.payload).with_context(|| { format!( "refused CultCache envelope {:?} at key {:?}", entry.r#type, entry.key @@ -2917,8 +2937,30 @@ pub struct CultCache { /// Records a store holds that no registered type claims: (store index, the envelope as the store read it). Derived from the last /// pull and replaced with it; written nowhere else. It decides only refusals at its keys. foreign: Vec<(usize, CultCacheEnvelope)>, + /// Why the last pull of the backing stores was refused; `None` once a pull succeeds. While set, every write and removal is refused. + load_refused: Option, } +/// A write or removal refused because the cache's last load of its backing stores was refused. The cache holds no +/// image of those stores, so any write would persist an image that lacks the records it never read. Repair the store +/// named by `reason` (which names a type, a key and a clause, never a payload) and load again. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CultCacheLoadRefused { + pub reason: String, +} + +impl std::fmt::Display for CultCacheLoadRefused { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + formatter, + "CultCache refuses writes and removals: its load of the backing stores failed ({}); repair the store and load again", + self.reason + ) + } +} + +impl std::error::Error for CultCacheLoadRefused {} + /// A record a store holds that no registered type claims: carried, never served. #[derive(Clone, Debug, PartialEq, Eq)] pub struct ForeignRecord { @@ -2980,6 +3022,7 @@ impl CultCache { projectors: BTreeMap::new(), stores: Vec::new(), foreign: Vec::new(), + load_refused: None, } } @@ -3116,12 +3159,39 @@ impl CultCache { /// Reads every store into a staged image and replaces the cache's view /// only when all of them loaded, so a refused load admits nothing. pub fn pull_all_backing_stores(&mut self) -> Result<()> { + match self.stage_pull() { + Ok((loaded, foreign, holders)) => { + self.entries = loaded; + self.foreign = foreign; + self.index_holders = holders; + self.load_refused = None; + Ok(()) + } + Err(error) => { + self.load_refused = Some(format!("{error:#}")); + Err(error) + } + } + } + + /// The one gate of every write and removal: a cache whose load was refused holds no image of its stores. + fn require_loaded(&self) -> Result<()> { + match &self.load_refused { + Some(reason) => Err(CultCacheLoadRefused { reason: reason.clone() }.into()), + None => Ok(()), + } + } + + /// The image every store holds, judged whole, installed nowhere. + #[allow(clippy::type_complexity)] + fn stage_pull(&self) -> Result<(BTreeMap<(String, String), CultCacheEnvelope>, Vec<(usize, CultCacheEnvelope)>, IndexHolders)> { let mut loaded = BTreeMap::new(); let mut foreign = Vec::new(); let known_types: BTreeSet = self.definitions.keys().cloned().collect(); let schema_name_definitions = self.schema_name_definitions.clone(); for (index, registration) in self.stores.iter().enumerate() { for mut entry in registration.store.pull_all()? { + require_record_payload(&entry)?; let Some(canonical_type) = resolve_registered_type( &known_types, &schema_name_definitions, @@ -3154,10 +3224,7 @@ impl CultCache { } self.refuse_second_globals(&loaded)?; let holders = self.judge_image(&loaded)?; - self.entries = loaded; - self.foreign = foreign; - self.index_holders = holders; - Ok(()) + Ok((loaded, foreign, holders)) } /// The records the last pull found in a store that no registered type claims, sorted by key (UTF-16 order). The cache does not @@ -3383,6 +3450,7 @@ impl CultCache { } pub fn put(&mut self, key: impl Into, value: &T) -> Result { + self.require_loaded()?; let (entry, parsed) = self.prepare_entry(key, value)?; self.refuse_unread_slots(&entry.r#type, &entry.key)?; self.refuse_second_global(&self.entries, &entry.r#type, &entry.key)?; @@ -3460,6 +3528,7 @@ impl CultCache { } pub fn put_prepared_batch(&mut self, staged: Vec) -> Result<()> { + self.require_loaded()?; if staged.is_empty() { return Err(anyhow!("CultCache batch must contain at least one entry")); } @@ -3484,7 +3553,7 @@ impl CultCache { entry.r#type )); } - require_payload_depth(entry)?; + require_record_payload(entry)?; self.refuse_unread_slots(&entry.r#type, &entry.key)?; self.refuse_claimed_id(entry)?; let Some(home) = self.home_index(&entry.r#type)? else { @@ -3527,6 +3596,7 @@ impl CultCache { } pub fn put_envelope(&mut self, entry: CultCacheEnvelope) -> Result { + self.require_loaded()?; let parsed = self.validate_envelope::(&entry)?; self.refuse_unread_slots(&entry.r#type, &entry.key)?; self.refuse_claimed_id(&entry)?; @@ -3547,6 +3617,7 @@ impl CultCache { /// admission; CultCache still enforces registered type, identity, timestamp, /// the payload nesting bound ([`MAX_PAYLOAD_DEPTH`]), routing, and persistence. pub fn put_raw_envelope(&mut self, entry: CultCacheEnvelope) -> Result<()> { + self.require_loaded()?; if !self.definitions.contains_key(&entry.r#type) { return Err(anyhow!( "No entry type registered for CultCache envelope type {:?}", @@ -3565,7 +3636,7 @@ impl CultCache { entry.r#type )); } - require_payload_depth(&entry)?; + require_record_payload(&entry)?; self.refuse_unread_slots(&entry.r#type, &entry.key)?; self.refuse_claimed_id(&entry)?; self.refuse_second_global(&self.entries, &entry.r#type, &entry.key)?; @@ -3621,6 +3692,7 @@ impl CultCache { } pub fn delete(&mut self, key: &str) -> Result { + self.require_loaded()?; self.require_entry_type::()?; self.refuse_unread_slots(T::TYPE, key)?; let id = entry_id_parts(T::TYPE, key); @@ -3662,6 +3734,7 @@ impl CultCache { entry.r#type )); } + require_record_payload(&entry)?; self.home_index(&entry.r#type)?; self.refuse_claimed_id(&entry)?; self.refuse_retype(&loaded, &self.foreign, &entry)?; @@ -6296,6 +6369,34 @@ mod tests { Ok(()) } + #[test] + fn put_raw_envelope_refuses_bytes_after_the_value() -> Result<()> { + let temp = tempfile::tempdir()?; + let store_path = temp.path().join("tail.cc"); + let mut cache = CultCache::new(); + cache.register_entry_type::()?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; + let tailed = CultCacheEnvelope { + key: "app".to_string(), + r#type: Settings::TYPE.to_string(), + payload: vec![0x91, 0x01, 0xc0], + stored_at: now_utc_second(), + schema_id: None, + }; + let error = format!("{:#}", cache.put_raw_envelope(tailed.clone()).unwrap_err()); + assert!(error.contains("payload has 1 bytes after its first MessagePack value"), "{error}"); + assert!(error.contains("app") && error.contains(Settings::TYPE), "{error}"); + assert!(cache.snapshot().is_empty()); + assert!(!store_path.exists() || SingleFileMessagePackBackingStore::new(&store_path).pull_all()?.is_empty()); + let error = format!("{:#}", cache.put_prepared_batch(vec![tailed.clone()]).unwrap_err()); + assert!(error.contains("payload has 1 bytes after its first MessagePack value"), "{error}"); + assert!(cache.snapshot().is_empty()); + // The same bytes without the tail are admitted: the refusal is the count, not the shape. + cache.put_raw_envelope(CultCacheEnvelope { payload: vec![0x91, 0x01], ..tailed })?; + assert_eq!(cache.snapshot().len(), 1); + Ok(()) + } + #[test] fn soa_snapshot_round_trips_registered_entries() -> Result<()> { let temp = tempfile::tempdir()?; diff --git a/packages/cultcache-rs/tests/slot_readonly.rs b/packages/cultcache-rs/tests/slot_readonly.rs index 5bffcd9a..0031e372 100644 --- a/packages/cultcache-rs/tests/slot_readonly.rs +++ b/packages/cultcache-rs/tests/slot_readonly.rs @@ -481,9 +481,11 @@ fn load_soa_refuses_a_same_length_replacement_of_a_marked_record() { let (mut cache, _dir, _) = held_marked_and_clean(); let all = cache.snapshot_envelopes(); let marked = all.iter().find(|held| held.key == "marked").unwrap(); - // The clean record's bytes padded to the marked record's length: same length, different bytes. - let mut same_length = vec![0xc0; marked.payload.len()]; - same_length[..2].copy_from_slice(&[0x91, 0xa1]); + // One value (a bin16) of the marked record's length: same length, different bytes. A value followed by padding + // would be refused at admission as bytes after the value, before the read-only check is reached. + let length = marked.payload.len(); + let mut same_length = vec![0u8; length]; + same_length[..3].copy_from_slice(&[0xc5, ((length - 3) >> 8) as u8, (length - 3) as u8]); assert_eq!(same_length.len(), marked.payload.len()); let mut replaced = all.clone(); replaced.iter_mut().find(|held| held.key == "marked").unwrap().payload = same_length; @@ -491,3 +493,47 @@ fn load_soa_refuses_a_same_length_replacement_of_a_marked_record() { assert_refused_with(&error, "marked", &mark("Composition", 3, 2, 1)); assert_eq!(cache.snapshot_envelopes(), all); } + +// The payload check runs before the mark scope opens: a tail on a slot-bearing payload is refused by count, never marked. +#[test] +fn a_tail_on_a_slot_payload_is_refused_before_any_mark() { + let mut tailed = vector("extra-slot-record.msgpack"); + tailed.push(0xc0); + let error = decode_payload_marked::(&tailed).unwrap_err(); + assert!(format!("{error:#}").contains("payload has 1 bytes after its first MessagePack value"), "{error:#}"); + // Nothing leaked: the next marked decode finds exactly its own mark. + let found = decode_payload_marked::(&vector("extra-slot-record.msgpack")).unwrap().1.unwrap(); + assert_eq!(found.occurrences, 1); +} + +// A store holding marked records and one with a tail is refused at load; the cache then refuses every write with the load +// refusal (not a per-key mark conflict), and the store file is untouched. +#[test] +fn a_store_with_marked_records_and_a_tail_is_refused_and_its_cache_writes_nothing() { + use cultcache_rs::CacheBackingStore; + use cultcache_rs::CultCacheLoadRefused; + let (mut held, _dir, path) = held_marked_and_clean(); + let mut tailed = vector("union-surface.msgpack"); + tailed.push(0xc0); + SingleFileMessagePackBackingStore::new(&path).push(&envelope("tailed", tailed)).unwrap(); + let before = std::fs::read(&path).unwrap(); + // The cache that held the store re-reads it and is refused. + assert!(format!("{:#}", held.pull_all_backing_stores().unwrap_err()).contains("bytes after")); + let fresh_value = constructed(); + let mut fresh = registered(); + fresh.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap(); + assert!(fresh.pull_all_backing_stores().is_err()); + for cache in [&mut held, &mut fresh] { + let staged = cache.prepare_entry("new", &fresh_value).unwrap().0; + let errors = [ + cache.put("new", &fresh_value).unwrap_err(), + cache.put_prepared_batch(vec![staged.clone()]).unwrap_err(), + cache.put_raw_envelope(staged).unwrap_err(), + cache.delete::("new").unwrap_err(), + ]; + for error in errors { + assert!(error.downcast_ref::().is_some(), "{error:#}"); + } + } + assert_eq!(std::fs::read(&path).unwrap(), before); +} diff --git a/packages/cultcache-rs/tests/union_parity.rs b/packages/cultcache-rs/tests/union_parity.rs index c51bd3d8..67f2b389 100644 --- a/packages/cultcache-rs/tests/union_parity.rs +++ b/packages/cultcache-rs/tests/union_parity.rs @@ -4,9 +4,13 @@ use cultcache_rs::CultCache; use cultcache_rs::CacheBackingStore; use cultcache_rs::CultCacheEnvelope; +use cultcache_rs::CultCacheLoadRefused; +use cultcache_rs::CultCacheSoaTable; use cultcache_rs::MAX_PAYLOAD_DEPTH; use cultcache_rs::PayloadTooDeep; use cultcache_rs::check_payload_depth; +use cultcache_rs::decode_payload; +use cultcache_rs::RedbMessagePackBackingStore; use cultcache_rs::DatabaseEntry; use cultcache_rs::SingleFileMessagePackBackingStore; @@ -464,7 +468,7 @@ fn envelope(key: &str, payload: &[u8]) -> CultCacheEnvelope { } // A payload past the bound cannot be admitted through the cache, so a store file written behind its -// back stands in for one a peer or an older runtime left. +// back stands in for one a peer or an older runtime left; load or read refuses it. fn through_cache(payload: &[u8]) -> Result { let temp = tempfile::tempdir().unwrap(); let path = temp.path().join("foreign.cc"); @@ -472,7 +476,10 @@ fn through_cache(payload: &[u8]) -> Result { let mut cache = CultCache::new(); cache.register_entry_type::().unwrap(); cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap(); - cache.pull_all_backing_stores().unwrap(); + // The payload check runs at load, so a payload past the bound is refused there, before any read. + if let Err(error) = cache.pull_all_backing_stores() { + return Err(format!("{error:#}")); + } match cache.get::("k") { Ok(value) => Ok(value.expect("the envelope was stored")), Err(error) => Err(format!("{error:#}")), @@ -529,6 +536,157 @@ fn bounds_vectors() { assert_eq!((accepted, refused, read_only), (4, 7, 1)); } +// A payload is one MessagePack value (bytes-after-outcomes.msgpack): every door refuses a tail by +// its count, names the clause, never the depth limit (the tail is not walked) and never a value. +#[test] +fn bytes_after_vectors() { + let rows: Vec = rmp_serde::from_slice(&vector("bytes-after-outcomes.msgpack")).unwrap(); + assert_eq!(rows.len(), 4); + let surface = vector("union-surface.msgpack"); + for row in &rows { + let fields = row.as_array().unwrap(); + let (file, outcome, union, detail) = (fields[0].as_str().unwrap(), fields[1].as_str().unwrap(), fields[2].as_str().unwrap(), fields[4].as_str().unwrap()); + assert_eq!((outcome, union, detail), ("refused", "UnionSurface", "bytes-after"), "{file}"); + let bytes = vector(file); + assert_eq!(&bytes[..surface.len()], &surface[..], "{file}"); + let clause = format!("payload has {} bytes after its first MessagePack value", bytes.len() - surface.len()); + let check = |door: &str, message: &str| { + assert!(message.contains(&clause), "{file} {door}: {message}"); + assert!(!message.contains("the limit is"), "{file} {door} walked the tail: {message}"); + assert!(!message.contains("CANARY-bytes-after-7d1e"), "{file} {door} echoed the tail: {message}"); + assert!(!message.contains(CANARY), "{file} {door} echoed a value: {message}"); + }; + let error = decode_payload::(&bytes).unwrap_err(); + assert!(error.downcast_ref::().is_none(), "{file}"); + check("decode_payload", &format!("{error:#}")); + // A store written behind the cache's back is refused at load, naming the type, the key and the count. + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("foreign.cc"); + SingleFileMessagePackBackingStore::new(&path).push(&envelope("k", &bytes)).unwrap(); + let mut single = CultCache::new(); + single.register_entry_type::().unwrap(); + single.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap(); + let message = format!("{:#}", single.pull_all_backing_stores().unwrap_err()); + check("single-file load", &message); + assert!(message.contains("\"k\"") && message.contains(UnionSurface::TYPE), "{file}: {message}"); + let redb_path = temp.path().join("foreign.redb"); + RedbMessagePackBackingStore::new(&redb_path).unwrap().push(&envelope("k", &bytes)).unwrap(); + let mut redb = CultCache::new(); + redb.register_entry_type::().unwrap(); + redb.add_generic_backing_store(RedbMessagePackBackingStore::new(&redb_path).unwrap()).unwrap(); + let message = format!("{:#}", redb.pull_all_backing_stores().unwrap_err()); + check("redb load", &message); + assert!(message.contains("\"k\"") && message.contains(UnionSurface::TYPE), "{file}: {message}"); + let mut soa = CultCache::new(); + soa.register_entry_type::().unwrap(); + let message = format!("{:#}", soa.load_soa(CultCacheSoaTable::from_envelopes(vec![envelope("k", &bytes)])).unwrap_err()); + check("load_soa", &message); + assert!(message.contains("\"k\"") && message.contains(UnionSurface::TYPE), "{file}: {message}"); + assert!(soa.snapshot().is_empty(), "{file}"); + // Raw admission refuses and stages nothing. + let mut raw = CultCache::new(); + raw.register_entry_type::().unwrap(); + let message = format!("{:#}", raw.put_raw_envelope(envelope("k", &bytes)).unwrap_err()); + check("put_raw_envelope", &message); + assert!(raw.snapshot().is_empty(), "{file}"); + } +} + +// Depth is found inside the first value, so a tail never hides it: a payload nested past the bound +// with a tail after it is the depth refusal, not bytes after. +#[test] +fn depth_beats_a_tail() { + let mut deep = vec![0x91u8; MAX_PAYLOAD_DEPTH + 1]; + deep.push(0xc0); + deep.push(0xc0); + let error = decode_payload::(&deep).unwrap_err(); + assert_eq!(error.downcast_ref::(), Some(&TOO_DEEP), "{error:#}"); +} + +// Depth is refused where the payload is loaded: a store holding one nested past the bound is not read. +#[test] +fn depth_is_refused_at_load() { + let mut deep = vec![0x91u8; MAX_PAYLOAD_DEPTH + 1]; + deep.push(0xc0); + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("deep.cc"); + SingleFileMessagePackBackingStore::new(&path).push(&envelope("k", &deep)).unwrap(); + let mut cache = CultCache::new(); + cache.register_entry_type::().unwrap(); + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap(); + let error = cache.pull_all_backing_stores().unwrap_err(); + assert_eq!(error.downcast_ref::(), Some(&TOO_DEEP), "{error:#}"); + assert!(cache.snapshot().is_empty()); +} + +// A cache whose load was refused refuses every write and removal and leaves the store as it was: a +// later write would otherwise persist the empty image over the records it never read. +#[test] +fn a_refused_load_refuses_every_write() { + let tail = { + let mut bytes = vector("union-surface.msgpack"); + bytes.push(0xc0); + bytes + }; + let good = envelope("good", &vector("union-surface.msgpack")); + let bad = envelope("bad", &tail); + for redb in [false, true] { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join(if redb { "seed.redb" } else { "seed.cc" }); + let mut cache = CultCache::new(); + cache.register_entry_type::().unwrap(); + if redb { + let mut store = RedbMessagePackBackingStore::new(&path).unwrap(); + store.push(&good).unwrap(); + store.push(&bad).unwrap(); + drop(store); + cache.add_generic_backing_store(RedbMessagePackBackingStore::new(&path).unwrap()).unwrap(); + } else { + let mut store = SingleFileMessagePackBackingStore::new(&path); + store.push(&good).unwrap(); + store.push(&bad).unwrap(); + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap(); + } + let load = cache.pull_all_backing_stores().unwrap_err(); + assert!(format!("{load:#}").contains("bytes after"), "{load:#}"); + let before = std::fs::read(&path).unwrap(); + let value = constructed(); + let staged = cache.prepare_entry("new", &value).unwrap().0; + let results: Vec<(&str, anyhow::Error)> = vec![ + ("put", cache.put("new", &value).unwrap_err()), + ("put_prepared_batch", cache.put_prepared_batch(vec![staged.clone()]).unwrap_err()), + ("put_envelope", cache.put_envelope::(staged.clone()).unwrap_err()), + ("put_raw_envelope", cache.put_raw_envelope(staged.clone()).unwrap_err()), + ("update", cache.update::("good", |current| current.unwrap_or_else(constructed)).unwrap_err()), + ("delete", cache.delete::("good").unwrap_err()), + ("delete a key the image lacks", cache.delete::("absent").unwrap_err()), + ]; + for (door, error) in results { + let refusal = error.downcast_ref::().unwrap_or_else(|| panic!("{door} redb={redb}: {error:#}")); + assert!(refusal.to_string().contains("bytes after"), "{door}: {refusal}"); + } + assert_eq!(std::fs::read(&path).unwrap(), before, "redb={redb}: the store changed"); + assert!(cache.snapshot().is_empty()); + // Repairing the store and loading again lifts it. + if redb { + RedbMessagePackBackingStore::new(&path).unwrap().delete(&bad).unwrap(); + } else { + SingleFileMessagePackBackingStore::new(&path).delete(&bad).unwrap(); + } + cache.pull_all_backing_stores().unwrap(); + cache.put("new", &value).unwrap(); + } +} + +// A payload that ends early is not bytes after the value: the decoder refuses it, not the check. +#[test] +fn a_truncated_payload_is_the_decoders_not_bytes_after() { + let mut truncated = vec![0x91, 0xd9, 0x40]; + truncated.extend_from_slice(b"short"); + let message = format!("{:#}", decode_payload::(&truncated).unwrap_err()); + assert!(!message.contains("bytes after"), "{message}"); +} + fn wrap_chain(wraps: usize, leaf: Node) -> UnionSurface { let mut node = leaf; for _ in 0..wraps { diff --git a/packages/cultnet-rs/CHANGELOG.md b/packages/cultnet-rs/CHANGELOG.md index 384395ec..1a01dbe1 100644 --- a/packages/cultnet-rs/CHANGELOG.md +++ b/packages/cultnet-rs/CHANGELOG.md @@ -15,5 +15,6 @@ only in git history. ### Breaking +- A CultNet frame with bytes after its message is refused, by count (through `cultcache_rs::decode_payload`); the decoder used to read the message and drop the tail. No producer emits one. Behaviour change: the next release is a minor bump (0.x breaking). - `CultNetDocumentRegistry::register` returns `Result<&mut Self>` where it returned `&mut Self`. A binding that claims a document type or a schema id another binding already holds is refused with `cultcache_rs::SchemaConflictError` (naming the new binding's schema id and both document types) and nothing is bound; binding a type to the schema id it already holds still replaces that binding. Callers chain `register(...)?`. - `CultMeshCdnChunkRef` gains a public `id` field, the decimal text of `offset` (the reference's element id, slot 4). A manifest written before ids reads `""`; encoding writes the slot. diff --git a/packages/cultnet-rs/tests/cultnet.rs b/packages/cultnet-rs/tests/cultnet.rs index 64c57aaf..7bc84957 100644 --- a/packages/cultnet-rs/tests/cultnet.rs +++ b/packages/cultnet-rs/tests/cultnet.rs @@ -1810,12 +1810,10 @@ fn a_replicated_payload_nested_past_the_bound_is_refused_typed_on_apply() -> Res }; assert_eq!(documents.len(), 1); - backing.push(&envelope("past", limit + 1))?; - let mut cache = CultCache::new(); - cache.register_entry_type::()?; - cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store))?; - cache.pull_all_backing_stores()?; - let past = registry.create_raw_snapshot_response(&cache, "past", None, Some(&["past".to_string()]))?; + // The sender cannot load a payload past the bound, so the frame is the at-limit one with its bytes swapped. + let mut past = at_limit.clone(); + let CultNetMessage::SnapshotResponseRaw { documents, .. } = &mut past else { unreachable!() }; + documents[0].payload = envelope("past", limit + 1).payload; let mut target = CultCache::new(); target.register_entry_type::()?; let error = registry diff --git a/packages/cultnet-rs/tests/decode_door.rs b/packages/cultnet-rs/tests/decode_door.rs index fd5bab6a..c55a6404 100644 --- a/packages/cultnet-rs/tests/decode_door.rs +++ b/packages/cultnet-rs/tests/decode_door.rs @@ -190,6 +190,16 @@ fn message_past_the_bound_is_refused_typed() { }); } +// A frame is one message: cultcache's payload rule refuses bytes after it, by count. +#[test] +fn frame_with_bytes_after_is_refused() { + let mut bytes = hello_with(&nested(0)); + assert!(decode_message(bytes.clone(), BIG).is_ok()); + bytes.push(0xc0); + let error = format!("{:#}", decode_message(bytes, BIG).unwrap_err()); + assert!(error.contains("1 bytes after its first MessagePack value"), "{error}"); +} + fn raw_v1_bytes(header_unknown: Option<&[u8]>) -> Vec { let mut header: Vec<(rmpv::Value, rmpv::Value)> = vec![ ("schemaId".into(), "s".into()), From 66bea107d5ce61e2871b3ca3dedd4d9c5cc6ec51 Mon Sep 17 00:00:00 2001 From: Libby Date: Thu, 12 Nov 2026 06:02:51 +0000 Subject: [PATCH 51/93] TypeScript: a payload is one MessagePack value (bytes-after-ts) (#130) --- packages/cultcache-ts/CHANGELOG.md | 2 + packages/cultcache-ts/README.md | 6 +- .../cultcache-ts/src/cult-cache-inspector.ts | 18 ++- packages/cultcache-ts/src/document.ts | 8 +- packages/cultcache-ts/src/payload-depth.ts | 39 ++++-- packages/cultcache-ts/src/wire.ts | 5 +- .../cultcache-ts/test/payload-depth.test.ts | 20 ++++ .../cultcache-ts/test/union-parity.test.ts | 113 +++++++++++++++++- .../test/provider-session-wire.test.ts | 13 ++ packages/cultnet-ts/test/content.test.ts | 7 ++ packages/cultnet-ts/test/cultnet.test.ts | 23 ++++ 11 files changed, 229 insertions(+), 25 deletions(-) diff --git a/packages/cultcache-ts/CHANGELOG.md b/packages/cultcache-ts/CHANGELOG.md index b91a0535..2da49fe9 100644 --- a/packages/cultcache-ts/CHANGELOG.md +++ b/packages/cultcache-ts/CHANGELOG.md @@ -24,10 +24,12 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following ### Added +- `checkPayloadDepth` returns the offset where the first value ends, or `undefined` when the payload ends early (a header, a container or a str/bin/scalar body cut short; it was `void`); it still refuses only depth. The bytes-after refusal is internal (`checkPayloadIsOneValue`, not exported); no new public name. - `CultCache.foreignRecords()`: the `{key, schemaId, schemaName, storedAt}` of every record the last pull carried and no registered type claims, sorted by key ordinal. ### Changed +- A document payload is exactly one MessagePack value (cultlib-gaps:ruling:trailing-bytes-refuse). `decodeDocumentPayload`, the one reader (store loads, `put`/`putEnvelope`, CultNet replication, the provider command), and the inspector refuse bytes after the first value with `document type "T" payload has N bytes after its first MessagePack value.`, counted from the walk's end and never walked or echoed; a payload that ends early stays the decoder's to refuse. The refused set is unchanged; only the text changed (it was `@msgpack`'s `RangeError` "Extra N of M byte(s)" for a plain document and the codec reader's "bytes after its value" for a codec document, and the inspector's preview no longer shows a tail), so code matching either text must match this one. `readWire` reads one value and no longer checks what follows. Stated limit: a definition with its own `formatter` is not walked, so whether its payload is one value is the formatter's. Implied bump: 0.16.0 (pre-1.0 minor, with the Breaking entries above). - Opening a legacy (pre-v1) store is a pure read: payloads that were not bytes are normalized in memory and the file is left as it was, so a codec that refuses the content cannot lose the old bytes. The next push, delete or flush rewrites the file as v1, merging what the open read. diff --git a/packages/cultcache-ts/README.md b/packages/cultcache-ts/README.md index 6fea306a..a081cd92 100644 --- a/packages/cultcache-ts/README.md +++ b/packages/cultcache-ts/README.md @@ -49,7 +49,9 @@ through `decodeDocumentPayload`, and when you need the bytes themselves (hashing write) take them with `documentPayloadBytes`, the sanctioned escape. - `decodeDocumentPayload(definition, payload)` reads a payload through the definition's formatter, - codec or plain map and then its schema. The definition is required. + codec or plain map and then its schema. The definition is required. A payload is exactly one + MessagePack value: bytes after the first are refused by count, naming the type, and never walked or + echoed (a payload that ends early is the decoder's to refuse). - `encodeDocumentPayload(definition, value)` is the writer: the schema first, then the formatter, codec or plain map. - `documentPayloadFromBytes(bytes)` is where bytes enter (a record you validated, a store read, a @@ -327,7 +329,7 @@ If you do not supply one, `CultCacheTS` uses MessagePack plus Zod validation by The 256-container nesting bound (`MAX_PAYLOAD_DEPTH`, refused with `CultCachePayloadDepthError`) covers the MessagePack the library itself writes: the default format and a codec. A payload your formatter writes is opaque bytes in any format, so the -library does not walk it as MessagePack; its format and its bounds are your formatter's. +library does not walk it as MessagePack; its format, its bounds and whether it is one value are your formatter's. Zod is no longer mandatory. Any schema object with a `parse(...)` function will do, which means generated Ajv-backed JSON-schema contracts can feed the cache diff --git a/packages/cultcache-ts/src/cult-cache-inspector.ts b/packages/cultcache-ts/src/cult-cache-inspector.ts index 1c359157..71643913 100644 --- a/packages/cultcache-ts/src/cult-cache-inspector.ts +++ b/packages/cultcache-ts/src/cult-cache-inspector.ts @@ -1,6 +1,6 @@ import { decode } from "@msgpack/msgpack"; -import { checkPayloadDepth } from "./payload-depth"; +import { checkPayloadDepth, checkPayloadIsOneValue } from "./payload-depth"; import { type StoreFormat, isStoreSnapshot, refuseUnreadStoreBytes, requireStoreSlots, requireV1RecordSlots } from "./store-format"; export interface CultCacheInspection { @@ -235,12 +235,22 @@ function decodeLegacyEnvelope(value: unknown): InspectedRecord { }; } -// The inspector's one payload read: the depth walk first, so a payload past the limit is refused before any decoder sees it. +// The inspector's one payload read: the depth walk first, so a payload past the limit is refused before any decoder sees it, +// and bytes after the first value are refused with the shared clause (the tail is never decoded or shown). function decodeBoundedPayload(payload: Uint8Array): unknown { - checkPayloadDepth(payload); + checkPayloadIsOneValue(payload, "document payload"); return decode(payload); } +// Where the first value ends (the whole payload when it ends early or cannot be walked): a preview never shows a tail. +function valueEnd(payload: Uint8Array): number { + try { + return checkPayloadDepth(payload) ?? payload.length; + } catch { + return payload.length; + } +} + function previewPayload(payload: Uint8Array): Pick { try { return { payloadPreview: toJsonSafe(decodeBoundedPayload(payload)) }; @@ -248,7 +258,7 @@ function previewPayload(payload: Uint8Array): Pick value.toString(16).padStart(2, "0")) .join(" "), }, diff --git a/packages/cultcache-ts/src/document.ts b/packages/cultcache-ts/src/document.ts index 8d295bd4..cb1ef589 100644 --- a/packages/cultcache-ts/src/document.ts +++ b/packages/cultcache-ts/src/document.ts @@ -1,6 +1,6 @@ import type { NotedSlots } from "./codec"; import { decodeMarked, resolveDocumentFormatter } from "./document-formatter"; -import { checkPayloadDepth } from "./payload-depth"; +import { checkPayloadDepth, checkPayloadIsOneValue } from "./payload-depth"; import type { AnyCultCacheDocumentDefinition, CultCacheSchemaCatalogMember, @@ -129,7 +129,11 @@ export function decodeDocumentPayloadMarked; unreadSlots?: NotedSlots } { const bytes = documentPayloadBytes(payload); - if (writesLibraryMessagePack(definition)) checkPayloadDepth(bytes, `document type "${definition.type}" payload`); + if (writesLibraryMessagePack(definition)) { + // A payload is exactly one MessagePack value: bytes after the first are refused by count before any formatter reads, + // so a tail is refused before any slot is marked. + checkPayloadIsOneValue(bytes, `document type "${definition.type}" payload`); + } const { value: decoded, unreadSlots } = decodeMarked(definition, bytes); const value = definition.schema.parse(decoded) as CultCacheDocumentValue; return unreadSlots ? { value, unreadSlots } : { value }; diff --git a/packages/cultcache-ts/src/payload-depth.ts b/packages/cultcache-ts/src/payload-depth.ts index 40ca7afe..45b8d5ca 100644 --- a/packages/cultcache-ts/src/payload-depth.ts +++ b/packages/cultcache-ts/src/payload-depth.ts @@ -12,8 +12,9 @@ export class CultCachePayloadDepthError extends Error { // Walks the headers of one MessagePack value iteratively (a count stack bounded by the limit, scalars skipped by length, // nothing read) and throws at the first container past the limit. The message names the subject, the depth and the limit, -// never payload bytes. A payload that ends early is the reader's to refuse. -export function checkPayloadDepth(bytes: Uint8Array, subject = "document payload"): void { +// never payload bytes. Returns the offset where the first value ends, or undefined when the payload ends early (the +// reader's to refuse); what follows that offset is never walked, and whether it may exist is the caller's rule. +export function checkPayloadDepth(bytes: Uint8Array, subject = "document payload"): number | undefined { const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); const remaining = new Float64Array(MAX_PAYLOAD_DEPTH); let depth = 0; @@ -33,12 +34,12 @@ export function checkPayloadDepth(bytes: Uint8Array, subject = "document payload else if (code <= 0x8f) { container = true; header = 1; children = 2 * (code & 0x0f); } else if (code <= 0x9f) { container = true; header = 1; children = code & 0x0f; } else if (code <= 0xbf) skip = 1 + (code & 0x1f); - else if (code === 0xc4 || code === 0xd9) { const n = width(1); if (n === undefined) return; skip = 2 + n; } - else if (code === 0xc5 || code === 0xda) { const n = width(2); if (n === undefined) return; skip = 3 + n; } - else if (code === 0xc6 || code === 0xdb) { const n = width(4); if (n === undefined) return; skip = 5 + n; } - else if (code === 0xc7) { const n = width(1); if (n === undefined) return; skip = 3 + n; } - else if (code === 0xc8) { const n = width(2); if (n === undefined) return; skip = 4 + n; } - else if (code === 0xc9) { const n = width(4); if (n === undefined) return; skip = 6 + n; } + else if (code === 0xc4 || code === 0xd9) { const n = width(1); if (n === undefined) return undefined; skip = 2 + n; } + else if (code === 0xc5 || code === 0xda) { const n = width(2); if (n === undefined) return undefined; skip = 3 + n; } + else if (code === 0xc6 || code === 0xdb) { const n = width(4); if (n === undefined) return undefined; skip = 5 + n; } + else if (code === 0xc7) { const n = width(1); if (n === undefined) return undefined; skip = 3 + n; } + else if (code === 0xc8) { const n = width(2); if (n === undefined) return undefined; skip = 4 + n; } + else if (code === 0xc9) { const n = width(4); if (n === undefined) return undefined; skip = 6 + n; } else if (code === 0xca || code === 0xce || code === 0xd2) skip = 5; else if (code === 0xcb || code === 0xcf || code === 0xd3) skip = 9; else if (code === 0xcc || code === 0xd0) skip = 2; @@ -47,10 +48,10 @@ export function checkPayloadDepth(bytes: Uint8Array, subject = "document payload else if (code === 0xd6) skip = 6; else if (code === 0xd7) skip = 10; else if (code === 0xd8) skip = 18; - else if (code === 0xdc) { const n = width(2); if (n === undefined) return; container = true; header = 3; children = n; } - else if (code === 0xdd) { const n = width(4); if (n === undefined) return; container = true; header = 5; children = n; } - else if (code === 0xde) { const n = width(2); if (n === undefined) return; container = true; header = 3; children = 2 * n; } - else { const n = width(4); if (n === undefined) return; container = true; header = 5; children = 2 * n; } + else if (code === 0xdc) { const n = width(2); if (n === undefined) return undefined; container = true; header = 3; children = n; } + else if (code === 0xdd) { const n = width(4); if (n === undefined) return undefined; container = true; header = 5; children = n; } + else if (code === 0xde) { const n = width(2); if (n === undefined) return undefined; container = true; header = 3; children = 2 * n; } + else { const n = width(4); if (n === undefined) return undefined; container = true; header = 5; children = 2 * n; } if (container) { if (depth === MAX_PAYLOAD_DEPTH) throw new CultCachePayloadDepthError(MAX_PAYLOAD_DEPTH + 1, MAX_PAYLOAD_DEPTH, subject); @@ -63,8 +64,20 @@ export function checkPayloadDepth(bytes: Uint8Array, subject = "document payload position += skip; } + // A scalar whose declared length runs past the payload is an early end, not a value. + if (position > bytes.length) return undefined; // One element is complete; close every container it was the last element of. while (depth > 0 && --remaining[depth - 1] === 0) depth--; - if (depth === 0) return; + if (depth === 0) return position; } + return undefined; +} + +// The one rule that a payload is exactly one MessagePack value: the depth walk, then a refusal of bytes after the first +// value, by count, naming the subject, from the walk's end. The tail is never walked or echoed; a payload that ends early +// stays the decoder's to refuse. decodeDocumentPayload and the inspector speak this clause and no other. +export function checkPayloadIsOneValue(bytes: Uint8Array, subject: string): number | undefined { + const end = checkPayloadDepth(bytes, subject); + if (end !== undefined && end < bytes.length) throw new Error(`${subject} has ${bytes.length - end} bytes after its first MessagePack value.`); + return end; } diff --git a/packages/cultcache-ts/src/wire.ts b/packages/cultcache-ts/src/wire.ts index 12bf572a..d351c446 100644 --- a/packages/cultcache-ts/src/wire.ts +++ b/packages/cultcache-ts/src/wire.ts @@ -128,7 +128,6 @@ export function readWire(payload: Uint8Array): unknown { } } - const tree = next(); - if (offset !== payload.length) throw new Error("the MessagePack payload has bytes after its value."); - return tree; + // One value is read; the caller owns what follows it (decodeDocumentPayload refuses a payload with a tail). + return next(); } diff --git a/packages/cultcache-ts/test/payload-depth.test.ts b/packages/cultcache-ts/test/payload-depth.test.ts index 382f0672..6fb81265 100644 --- a/packages/cultcache-ts/test/payload-depth.test.ts +++ b/packages/cultcache-ts/test/payload-depth.test.ts @@ -119,6 +119,19 @@ test("a payload that ends early, is empty or carries trailing bytes is the reade assert.doesNotThrow(() => checkPayloadDepth(bytes(0xc0))); }); +test("the walk returns where the first value ends, and undefined when the payload ends early", () => { + assert.equal(checkPayloadDepth(bytes(0xc0)), 1); + assert.equal(checkPayloadDepth(bytes(0xc0, 0xc1, 0xc1)), 1, "a top-level scalar ends at its own length; the tail is not judged"); + assert.equal(checkPayloadDepth(bytes(0x92, 0x01, 0xa1, 0x61, 0xc0)), 5 - 1, "a container ends when its last child completes"); + assert.equal(checkPayloadDepth(cat(bytes(0x90), arrays(L + 50))), 1, "the tail is never walked"); + assert.equal(checkPayloadDepth(bytes()), undefined); + assert.equal(checkPayloadDepth(bytes(0x92, 0x01)), undefined); + assert.equal(checkPayloadDepth(bytes(0xd9)), undefined); + for (const cut of [[0xcc], [0xcb, 0x3f, 0xf8], [0xa5, 0x61, 0x62, 0x63], [0xc4, 0x02, 0x01], [0xd7, 0x01]]) { + assert.equal(checkPayloadDepth(bytes(...cut)), undefined, `a scalar cut short: ${cut.map((value) => value.toString(16)).join(" ")}`); + } +}); + test("the walk is iterative: 100000 nested arrays are refused with the depth error, not a RangeError", () => { refused(arrays(100_000), "100000 nested arrays"); refused(chain(bytes(0x81, 0x00), 100_000), "100000 nested maps"); @@ -198,6 +211,13 @@ test("the inspector previews a payload at the limit and reports one past it, or } }); +test("the inspector refuses bytes after a payload's first value with the shared clause and previews no tail", async () => { + const file = await storeWith(bytes(0xc0, 0xc1, 0xc1)); + const record = inspectCultCacheBytes(file, await readFile(file)).records[0]!; + assert.equal(record.payloadDecodeError, "document payload has 2 bytes after its first MessagePack value."); + assert.deepEqual(record.payloadPreview, { bytes: 3, hexPrefix: "c0" }); +}); + // A header whose length or count needs all four bytes: the low sixteen bits of the length are small, the whole is not. const be32 = (n: number) => bytes((n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff); diff --git a/packages/cultcache-ts/test/union-parity.test.ts b/packages/cultcache-ts/test/union-parity.test.ts index d688cb74..d917ea7a 100644 --- a/packages/cultcache-ts/test/union-parity.test.ts +++ b/packages/cultcache-ts/test/union-parity.test.ts @@ -338,7 +338,7 @@ test("the wire reader keeps 64-bit integers whole, tells a float from an integer assert.throws(() => readWire(fromHex("dd")), /truncated/u); assert.throws(() => readWire(fromHex("ddffffffff")), /truncated/u, "a huge array header reserves nothing"); assert.throws(() => readWire(fromHex("a5616263")), /truncated/u); - assert.throws(() => readWire(fromHex("0102")), /bytes after its value/u); + assert.equal(readWire(fromHex("0102")), 1, "the reader reads one value; the document owner refuses what follows"); assert.throws(() => readWire(fromHex("81a161a162")), /map, extension or reserved/u); assert.throws(() => readWire(fromHex("c1")), /map, extension or reserved/u); assert.throws(() => readWire(fromHex("d4010a")), /map, extension or reserved/u); @@ -725,3 +725,114 @@ test("a cache load refuses a store whose record is past the limit, naming the ty const refusing = CultCache.builder().withDocumentType(document).withGenericStore(memoryStore([envelopeOf(await read("depth-past-limit.msgpack"))])).build(); await assert.rejects(() => refusing.pullAllBackingStores(), (error: Error) => error instanceof CultCachePayloadDepthError && !error.message.includes(CANARY)); }); + +// ---- bytes-after vectors: a payload is exactly one MessagePack value (tests/vectors/union-parity/README.md) ---- + +// The C# reference writes the vectors: union-surface.msgpack followed by a tail the first value does not own. The one +// owner refuses by count, naming the type, from the walk's end; it never walks or echoes the tail. +async function bytesAfterEntries() { + const raw = decode(await read("bytes-after-outcomes.msgpack")) as unknown[][]; + return raw.map(([file, outcome, unionName, key, detail]) => ({ file: file as string, outcome: outcome as string, unionName: unionName as string, key: key as number | null, detail: detail as string })); +} + +// The C# vector writer's canary (BytesAfterVectorTests): it sits in the deep tail, so an echo of the tail would carry it. +const TAIL_CANARY = "CANARY-bytes-after-7d1e"; +const valueLength = async () => (await read("union-surface.msgpack")).length; +const plainDocument = () => defineDocumentType({ type: "tests.plain_surface", schema: z.unknown() }); +const codecDocument = () => defineDocumentType({ type: "tests.codec_surface", schema: surfaceSchema, codec: surfaceCodec }); + +async function assertBytesAfterRefused(file: string, tail: number, run: () => unknown | Promise, type: string) { + const clause = `${type}`; + await assert.rejects( + async () => run(), + (error: Error) => { + assert.ok(!(error instanceof CultCachePayloadDepthError), `${file}: the tail was walked`); + assert.ok(!error.message.includes(CANARY) && !error.message.includes(TAIL_CANARY), `${file}: the refusal echoed the tail`); + assert.ok(!error.message.includes("the limit is"), `${file}: the depth refusal`); + assert.ok(error.message.includes(clause), `${file}: names the type: ${error.message}`); + assert.ok(error.message.includes(`${tail} bytes after its first MessagePack value`), `${file}: names the count: ${error.message}`); + return true; + }, + file, + ); +} + +test("every bytes-after vector is refused by the one owner, by count, on the codec, plain and store paths", async () => { + const entries = await bytesAfterEntries(); + assert.equal(entries.length, 4); + const length = await valueLength(); + const surface = surfaceDocument(); + const plain = plainDocument(); + const codecOnly = codecDocument(); + const dir = await mkdtemp(join(tmpdir(), "bytes-after-")); + for (const { file, outcome, unionName, detail } of entries) { + assert.equal(outcome, "refused"); + assert.equal(unionName, "UnionSurface"); + assert.equal(detail, "bytes-after"); + const bytes = await read(file); + const tail = bytes.length - length; + assert.ok(tail > 0, file); + if (file === "bytes-after-deep-tail.msgpack") assert.ok(Buffer.from(bytes).includes(TAIL_CANARY), "the deep tail carries the canary the assertions look for"); + const payload = documentPayloadFromBytes(bytes); + await assertBytesAfterRefused(file, tail, () => decodeDocumentPayload(surface, payload), surface.type); + await assertBytesAfterRefused(file, tail, () => decodeDocumentPayload(plain, payload), plain.type); + await assertBytesAfterRefused(file, tail, () => decodeDocumentPayload(codecOnly, payload), codecOnly.type); + + // The store path: a single-file store holding the vector as a record payload refuses on load. + const path = join(dir, `${file}.cc`); + await new SingleFileMessagePackBackingStore(path).push(envelopeOf(bytes)); + const cache = CultCache.builder().withDocumentType(surface).withGenericStore(new SingleFileMessagePackBackingStore(path)).build(); + await assertBytesAfterRefused(file, tail, () => cache.pullAllBackingStores(), surface.type); + + // The put path: an envelope arriving with the tail is refused and held nowhere. + const written: CultCacheEnvelope[] = []; + const held = CultCache.builder().withDocumentType(surface).withGenericStore(memoryStore(written)).build(); + await assertBytesAfterRefused(file, tail, () => held.putEnvelope(surface, envelopeOf(bytes)), surface.type); + assert.equal(written.length, 0, `${file}: nothing reached the store`); + assert.equal(held.get(surface, "surface"), undefined, `${file}: nothing is held in memory`); + } +}); + +test("a payload with an unread slot and a tail is refused by count before any slot is marked or the record held", async () => { + const withSlot = await read("extra-slot-record.msgpack"); + const surface = surfaceDocument(); + const tailed = Uint8Array.from([...withSlot, 0xc0]); + const whole = CultCache.builder().withDocumentType(surface).withGenericStore(memoryStore([envelopeOf(withSlot)])).build(); + await whole.pullAllBackingStores(); + assert.ok(whole.unreadSlots(surface, "surface"), "without a tail the record is held with its unread slots"); + + const loading = CultCache.builder().withDocumentType(surface).withGenericStore(memoryStore([envelopeOf(tailed)])).build(); + await assertBytesAfterRefused("extra-slot-record+tail", 1, () => loading.pullAllBackingStores(), surface.type); + assert.equal(loading.get(surface, "surface"), undefined); + assert.equal(loading.unreadSlots(surface, "surface"), undefined); + + const written: CultCacheEnvelope[] = []; + const putting = CultCache.builder().withDocumentType(surface).withGenericStore(memoryStore(written)).build(); + await assertBytesAfterRefused("extra-slot-record+tail", 1, () => putting.putEnvelope(surface, envelopeOf(tailed)), surface.type); + assert.equal(written.length, 0); + assert.equal(putting.get(surface, "surface"), undefined); + assert.equal(putting.unreadSlots(surface, "surface"), undefined); +}); + +test("a payload that ends early is not bytes after its value: the decoder refuses it, and a user formatter's bytes are not walked", async () => { + const whole = await read("union-surface.msgpack"); + const surface = surfaceDocument(); + for (const cut of [0, 1, 10, whole.length - 1]) { + assert.throws( + () => decodeDocumentPayload(surface, documentPayloadFromBytes(whole.slice(0, cut))), + (error: Error) => !error.message.includes("bytes after") && !(error instanceof CultCachePayloadDepthError), + `truncated at ${cut}`, + ); + } + // A header cut short of its length is an early end too, whatever width it declares. + for (const early of ["dc00", "dd000000", "de00", "c4", "d9", "a5616263", "9291"]) { + assert.throws( + () => decodeDocumentPayload(surface, documentPayloadFromBytes(fromHex(early))), + (error: Error) => !error.message.includes("bytes after") && !(error instanceof CultCachePayloadDepthError), + `early end ${early}`, + ); + } + const opaque = defineDocumentType({ type: "tests.opaque", schema: z.unknown(), formatter: { encode: (value: Uint8Array) => value, decode: (bytes: Uint8Array) => bytes } }); + const tailed = await read("bytes-after-nil.msgpack"); + assert.equal((decodeDocumentPayload(opaque, documentPayloadFromBytes(tailed)) as Uint8Array).length, tailed.length); +}); diff --git a/packages/cultmesh-ts/test/provider-session-wire.test.ts b/packages/cultmesh-ts/test/provider-session-wire.test.ts index 4905f161..69314fbe 100644 --- a/packages/cultmesh-ts/test/provider-session-wire.test.ts +++ b/packages/cultmesh-ts/test/provider-session-wire.test.ts @@ -83,3 +83,16 @@ test("a provider command that is not a map, or lacks a required field, is refuse (error: Error) => error.message === "CultMesh provider command must be a MessagePack map.", ); }); + +test("a provider command payload with bytes after its first value is refused by count, without echoing the tail", () => { + const command = { commandId: "c1", commandKind: "k", providerId: "p", serviceInstanceId: "s", payload: { n: 1 } }; + const whole = documentPayloadBytes(encodeDocumentPayload(providerCommandDocument, command)); + const tailed = documentPayloadFromBytes(Uint8Array.from([...whole, ...Buffer.from("canary-tail-8c1f")])); + assert.throws( + () => decodeProviderCommandDocument({ + schemaId: cultMeshProviderSessionSchemas.command, recordKey: "p/s/c1", storedAt: "2026-10-11T00:00:00Z", + payloadEncoding: "messagepack" as const, payload: tailed, + }), + (error: Error) => error.message.includes("16 bytes after its first MessagePack value") && !error.message.includes("canary-tail-8c1f"), + ); +}); diff --git a/packages/cultnet-ts/test/content.test.ts b/packages/cultnet-ts/test/content.test.ts index ec23b64e..e2ae5ed9 100644 --- a/packages/cultnet-ts/test/content.test.ts +++ b/packages/cultnet-ts/test/content.test.ts @@ -392,6 +392,13 @@ test("decodeManifest accepts and refuses exactly what the reference does, and re } }); +test("a manifest with bytes after its first value is refused by count, whatever the tail is", () => { + const whole = encodeManifest(manifestOf()); + for (const tail of [[0xc0], [0xc1, 0xc1], [0x90]]) { + assert.throws(() => decodeManifest(Uint8Array.from([...whole, ...tail])), new RegExp(`${tail.length} bytes after its first MessagePack value`)); + } +}); + test("a manifest written as a map names the manifest, not a field inside it", () => { assert.throws(() => decodeManifest(encode({ 0: "a", 9: { k: "v" } })), /field 'manifest' has the wrong type/); }); diff --git a/packages/cultnet-ts/test/cultnet.test.ts b/packages/cultnet-ts/test/cultnet.test.ts index ac4f539d..581fd66a 100644 --- a/packages/cultnet-ts/test/cultnet.test.ts +++ b/packages/cultnet-ts/test/cultnet.test.ts @@ -14,6 +14,8 @@ import { SchemaConflictError, SingleFileMessagePackBackingStore, defineDocumentType, + documentPayloadBytes, + documentPayloadFromBytes, schemaIdentityOf, } from "@gamecult/cultcache-ts"; @@ -3140,3 +3142,24 @@ test("CultNet registry names the canonical schema id of a compatible alias, and assert.equal(registry.canonicalSchemaId("p3.nobody"), undefined); assert.equal(registry.canonicalSchemaId("p3.shared"), undefined, "an id several bindings claim resolves to none"); }); + +test("a replicated record with bytes after its first MessagePack value is refused by count and held nowhere", async () => { + const definition = defineDocumentType({ type: "tails.note", schemaId: "tails.note.v1", schema: z.object({ body: z.string() }) }); + const registry = new CultNetDocumentRegistry([defineCultNetDocumentBinding({ definition })]); + const origin = CultCache.builder().withDocumentType(definition).build(); + await origin.put(definition, "k", { body: "x" }); + const document = registry.createRawSnapshotResponse(origin, "tails").documents[0]!; + const whole = documentPayloadBytes(document.payload); + const target = CultCache.builder().withDocumentType(definition).build(); + await assert.rejects( + registry.applyRawDocumentPutMessage(target, { + schemaVersion: "cultnet.document_put_raw.v0", + messageId: "tails", + document: { ...document, payload: documentPayloadFromBytes(Uint8Array.from([...whole, 0xc0, 0xc1])) }, + }), + (error: Error) => error.message.includes('document type "tails.note" payload has 2 bytes after its first MessagePack value'), + ); + assert.deepEqual(target.snapshot(), []); + await registry.applyRawDocumentPutMessage(target, { schemaVersion: "cultnet.document_put_raw.v0", messageId: "whole", document }); + assert.deepEqual(target.getRequired(definition, "k"), { body: "x" }); +}); From 12f3801893956272a26d61413ea98301251e7171 Mon Sep 17 00:00:00 2001 From: Libby Date: Thu, 12 Nov 2026 08:01:32 +0000 Subject: [PATCH 52/93] Rust: a cache must pull its stores before it writes (cache-must-pull-before-write) (#133) --- packages/cultcache-rs/CHANGELOG.md | 1 + packages/cultcache-rs/src/lib.rs | 170 +++++++++++++++++-- packages/cultcache-rs/tests/slot_readonly.rs | 2 + packages/cultcache-rs/tests/union_parity.rs | 2 + 4 files changed, 160 insertions(+), 15 deletions(-) diff --git a/packages/cultcache-rs/CHANGELOG.md b/packages/cultcache-rs/CHANGELOG.md index 4e97588c..c6d72492 100644 --- a/packages/cultcache-rs/CHANGELOG.md +++ b/packages/cultcache-rs/CHANGELOG.md @@ -16,6 +16,7 @@ Semver: this is a breaking change in the 0.x line (0.3.x to 0.4.0). - A unit arm with slots (`[N, [extra...]]`) decodes as the unit arm and marks the record, where the derive refused it. - The registration map's value changes from the schema name to a private struct holding the schema name and the unread-slots check. - A cache whose last `pull_all_backing_stores` was refused (a tail, a payload past the nesting bound, a duplicate unique-index value, a retype conflict, a record from the wrong store) refuses every `put`, `put_envelope`, `put_raw_envelope`, `put_prepared_batch`, `update` and `delete` with `CultCacheLoadRefused` until a pull succeeds. Before, it stayed writable over an empty image, and the next batch write replaced the store with only the new records. +- A cache with a backing store also refuses every write and removal, with `CultCacheLoadRefused`, until it has pulled: a cache that never pulled, or that gained a store since its last pull, holds no image of its stores, and a batch from it replaced a populated store with only the new records. `is_writable` answers false meanwhile. A cache with no store needs no pull. Callers that attach a store and write (cultmesh nodes created with `pull_on_start: false`) must call `pull_all_backing_stores` first. - `decode_payload`, `put_raw_envelope` and prepared batches refuse a payload with bytes after its first MessagePack value (`payload has {n} bytes after its first MessagePack value`), by count; the tail is never walked or quoted. They used to read the first value and keep or drop the tail, so a record with a stray tail was served and rewritten without it. A store holding such a record (single-file, redb, `load_soa`) is refused at load, naming the type, the key and the count, and stays unreadable until the record is repaired; the same check now runs on every record at load, as the C# and Python runtimes do at open. The nesting bound is part of that check, so a stored payload nested past it is refused at load too, where it used to load and refuse on read. - A single-file store written as `cultcache.store.v3` is read and written: a store the C# runtime marked v3 (it holds a record with an element id: an owned list element with a `[CultElementId]` member) keeps its header here, and is carried through every rewrite with its ids; this runtime never drops the mark. A runtime from before this change refuses a v3 store by name. Rolling back to an older release after a store has been written as v3 is one-way: there is no carry-back, so restore from a copy taken before upgrading. - A write over a file the reader refuses (a zero-byte file, a truncated store, a stamp this runtime does not read) refuses with `CultCacheStoreUnreadable` and leaves the file as it is. A whole-store write used to replace such a file. This reverses 0.3.1, where `push_all` replaced a file with no readable stamp (healing it); the stamp rule now has one owner, the reader every write asks under the lock, and it accepts v1 and v3 (0.3.1 refused v3). diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index 522e226e..47a3dd18 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -2937,13 +2937,24 @@ pub struct CultCache { /// Records a store holds that no registered type claims: (store index, the envelope as the store read it). Derived from the last /// pull and replaced with it; written nowhere else. It decides only refusals at its keys. foreign: Vec<(usize, CultCacheEnvelope)>, - /// Why the last pull of the backing stores was refused; `None` once a pull succeeds. While set, every write and removal is refused. - load_refused: Option, + /// Whether the cache holds an image of its stores. While it is not `Pulled`, every write and removal is refused. + load: LoadState, } -/// A write or removal refused because the cache's last load of its backing stores was refused. The cache holds no -/// image of those stores, so any write would persist an image that lacks the records it never read. Repair the store -/// named by `reason` (which names a type, a key and a clause, never a payload) and load again. +/// What the cache knows of its stores. `Unpulled` is the state of a cache that has never pulled, or that gained a store +/// since: its image lacks whatever the stores hold, and a batch would replace them with it. +#[derive(Clone, Debug, PartialEq, Eq)] +enum LoadState { + Unpulled, + Pulled, + /// Why the last pull was refused. + Refused(String), +} + +/// A write or removal refused because the cache holds no image of its backing stores: its last load was refused, or it has +/// not pulled since its stores were attached. Any write would persist an image that lacks the records it never read. +/// Pull; for a refused load, first repair the store named by `reason` (which names a type, a key and a clause, never a +/// payload). #[derive(Clone, Debug, PartialEq, Eq)] pub struct CultCacheLoadRefused { pub reason: String, @@ -2953,7 +2964,7 @@ impl std::fmt::Display for CultCacheLoadRefused { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( formatter, - "CultCache refuses writes and removals: its load of the backing stores failed ({}); repair the store and load again", + "CultCache refuses writes and removals: it holds no image of its backing stores ({}); repair the store if it was refused and pull again", self.reason ) } @@ -3022,7 +3033,7 @@ impl CultCache { projectors: BTreeMap::new(), stores: Vec::new(), foreign: Vec::new(), - load_refused: None, + load: LoadState::Unpulled, } } @@ -3128,6 +3139,10 @@ impl CultCache { store: Box::new(store), types, }); + let loaded_before = self.load.clone(); + if self.load == LoadState::Pulled { + self.load = LoadState::Unpulled; + } // A record's home cannot change after it is admitted. let candidate = self.stores.len() - 1; let held: BTreeSet = self.entries.keys().map(|(type_id, _)| type_id.clone()).collect(); @@ -3146,6 +3161,7 @@ impl CultCache { self.describe_store(after) ); self.stores.pop(); + self.load = loaded_before; return Err(error); } } @@ -3164,21 +3180,27 @@ impl CultCache { self.entries = loaded; self.foreign = foreign; self.index_holders = holders; - self.load_refused = None; + self.load = LoadState::Pulled; Ok(()) } Err(error) => { - self.load_refused = Some(format!("{error:#}")); + self.load = LoadState::Refused(format!("{error:#}")); Err(error) } } } /// The one gate of every write and removal: a cache whose load was refused holds no image of its stores. + /// A cache without stores has nothing to lose and needs no pull. fn require_loaded(&self) -> Result<()> { - match &self.load_refused { - Some(reason) => Err(CultCacheLoadRefused { reason: reason.clone() }.into()), - None => Ok(()), + match &self.load { + _ if self.stores.is_empty() => Ok(()), + LoadState::Pulled => Ok(()), + LoadState::Refused(reason) => Err(CultCacheLoadRefused { reason: reason.clone() }.into()), + LoadState::Unpulled => Err(CultCacheLoadRefused { + reason: "pull_all_backing_stores has not run since the stores were attached".to_string(), + } + .into()), } } @@ -3269,9 +3291,12 @@ impl CultCache { /// Whether a write or removal of `(type_id, key)` would be admitted: false when the held payload carries slots past /// its registered type's declared slots, at any depth, or cannot be read by this build at all (the record is - /// read-only), when a store carries a foreign record at the key, or when another registered type holds it. True for a + /// read-only), when the cache holds no image of its stores (see [`CultCacheLoadRefused`]), when a store carries a foreign record at the key, or when another registered type holds it. True for a /// key nothing holds. An unregistered type is an error. Advisory: the write entries ask the same checks themselves. pub fn is_writable(&self, type_id: &str, key: &str) -> Result { + if self.require_loaded().is_err() { + return Ok(false); + } if !matches!(self.held_finding(type_id, key)?, HeldFinding::Free) { return Ok(false); } @@ -4724,6 +4749,7 @@ mod tests { let mut cache = CultCache::new(); cache.register_registry(TestEntries)?; cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(path))?; + cache.pull_all_backing_stores()?; records(&mut cache) } @@ -4965,6 +4991,7 @@ mod tests { let mut cache = CultCache::new(); cache.register_entry_type::()?; cache.add_backing_store(SingleFileMessagePackBackingStore::new(&routed), [Settings::TYPE])?; + cache.pull_all_backing_stores()?; cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&generic))?; cache.pull_all_backing_stores()?; let (staged, _) = cache.prepare_entry("other", &settings_one())?; @@ -5066,6 +5093,7 @@ mod tests { let mut cache = CultCache::new(); cache.register_registry(TestEntries)?; cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; + cache.pull_all_backing_stores()?; cache.put("shared", &settings_one())?; let before = std::fs::read(&store_path)?; @@ -5098,6 +5126,7 @@ mod tests { let mut cache = CultCache::new(); cache.register_registry(TestEntries)?; cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; + cache.pull_all_backing_stores()?; cache.put("kept", &settings_one())?; let before = std::fs::read(&store_path)?; @@ -5206,6 +5235,7 @@ mod tests { let mut cache = CultCache::new(); cache.register_registry(TestEntries)?; cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; + cache.pull_all_backing_stores()?; let (settings, _) = cache.prepare_entry( "app", @@ -5912,6 +5942,7 @@ mod tests { inner: SingleFileMessagePackBackingStore::new(&store_path), refuse_batch: refusal.clone(), })?; + cache.pull_all_backing_stores()?; cache.put( "app", &Settings { @@ -5952,6 +5983,7 @@ mod tests { ["settings"], )?; cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&generic_path))?; + cache.pull_all_backing_stores()?; cache.put( "app", @@ -5980,6 +6012,7 @@ mod tests { let temp = tempfile::tempdir()?; let mut cache = CultCache::new(); cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(temp.path().join("a.cc")))?; + cache.pull_all_backing_stores()?; let error = cache .add_generic_backing_store(SingleFileMessagePackBackingStore::new(temp.path().join("b.cc"))) .unwrap_err(); @@ -5993,6 +6026,7 @@ mod tests { let temp = tempfile::tempdir()?; let mut cache = CultCache::new(); cache.add_backing_store(SingleFileMessagePackBackingStore::new(temp.path().join("a.cc")), ["settings"])?; + cache.pull_all_backing_stores()?; let error = cache .add_backing_store( SingleFileMessagePackBackingStore::new(temp.path().join("b.cc")), @@ -6002,6 +6036,7 @@ mod tests { assert!(error.to_string().contains("already routed"), "{error}"); // A refused registration claims nothing, so "note" is still free. cache.add_backing_store(SingleFileMessagePackBackingStore::new(temp.path().join("c.cc")), ["note"])?; + cache.pull_all_backing_stores()?; assert_eq!(cache.stores.len(), 2); Ok(()) } @@ -6014,6 +6049,7 @@ mod tests { let mut cache = CultCache::new(); cache.register_entry_type::()?; cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&generic_path))?; + cache.pull_all_backing_stores()?; cache.put("app", &Settings { theme: "v1-generic".to_string(), retries: 1 })?; let error = cache @@ -6043,15 +6079,17 @@ mod tests { let mut writer = CultCache::new(); writer.register_entry_type::()?; writer.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&generic_path))?; + writer.pull_all_backing_stores()?; writer.put("app", &Settings { theme: "stray".to_string(), retries: 0 })?; let generic_bytes = fs::read(&generic_path)?; let mut cache = CultCache::new(); cache.register_entry_type::()?; cache.add_backing_store(SingleFileMessagePackBackingStore::new(&settings_path), ["settings"])?; - cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&generic_path))?; + cache.pull_all_backing_stores()?; // The cache already holds a record, in its home store, before the refused load. cache.put("held", &Settings { theme: "home".to_string(), retries: 0 })?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&generic_path))?; let before = cache.snapshot(); let settings_bytes = fs::read(&settings_path)?; let error = cache.pull_all_backing_stores().unwrap_err(); @@ -6075,7 +6113,9 @@ mod tests { let mut cache = CultCache::new(); cache.register_entry_type::()?; cache.add_backing_store(SingleFileMessagePackBackingStore::new(&settings_path), [Settings::TYPE])?; + cache.pull_all_backing_stores()?; cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&generic_path))?; + cache.pull_all_backing_stores()?; cache.put("app", &Settings { theme: "before".to_string(), retries: 1 })?; let before = cache.snapshot(); @@ -6117,6 +6157,7 @@ mod tests { let mut homeless = CultCache::new(); homeless.register_registry(TestEntries)?; homeless.add_backing_store(SingleFileMessagePackBackingStore::new(temp.path().join("settings.cc")), ["settings"])?; + homeless.pull_all_backing_stores()?; homeless.put("held", &Settings { theme: "held".to_string(), retries: 0 })?; let before = homeless.snapshot(); let error = homeless.load_soa(table).unwrap_err(); @@ -6133,7 +6174,9 @@ mod tests { let mut cache = CultCache::new(); cache.register_registry(TestEntries)?; cache.add_backing_store(SingleFileMessagePackBackingStore::new(&settings_path), ["settings"])?; + cache.pull_all_backing_stores()?; cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&generic_path))?; + cache.pull_all_backing_stores()?; cache.put("memo", &Note { title: "generic".to_string(), body: "home".to_string() })?; cache.put("old", &Settings { theme: "kept".to_string(), retries: 0 })?; @@ -6164,6 +6207,7 @@ mod tests { let mut cache = CultCache::new(); cache.register_registry(TestEntries)?; cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; + cache.pull_all_backing_stores()?; cache.put("app", &Settings { theme: "before".to_string(), retries: 1 })?; let before = cache.snapshot(); let store_bytes = fs::read(&store_path)?; @@ -6203,6 +6247,7 @@ mod tests { let mut cache = CultCache::new(); cache.register_entry_type::()?; cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; + cache.pull_all_backing_stores()?; let updated = cache.update::("app", |current| { let mut current = current.unwrap_or(Settings { @@ -6225,6 +6270,7 @@ mod tests { let mut cache = CultCache::new(); cache.register_entry_type::()?; cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; + cache.pull_all_backing_stores()?; cache.put( "app", &Settings { @@ -6288,6 +6334,7 @@ mod tests { let mut origin = CultCache::new(); origin.register_entry_type::()?; origin.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&origin_store))?; + origin.pull_all_backing_stores()?; origin.put( "app", &Settings { @@ -6301,6 +6348,7 @@ mod tests { let mut target = CultCache::new(); target.register_entry_type::()?; target.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&target_store))?; + target.pull_all_backing_stores()?; let applied = target.put_envelope::(envelope.clone())?; assert_eq!( @@ -6334,6 +6382,7 @@ mod tests { cache.register_entry_type::()?; cache.register_entry_type::()?; cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; + cache.pull_all_backing_stores()?; cache.put_raw_envelope(envelope.clone())?; assert_eq!(cache.snapshot(), vec![envelope.clone()]); @@ -6376,6 +6425,7 @@ mod tests { let mut cache = CultCache::new(); cache.register_entry_type::()?; cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; + cache.pull_all_backing_stores()?; let tailed = CultCacheEnvelope { key: "app".to_string(), r#type: Settings::TYPE.to_string(), @@ -6404,6 +6454,7 @@ mod tests { let mut cache = CultCache::new(); cache.register_registry(TestEntries)?; cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; + cache.pull_all_backing_stores()?; cache.put( "app", &Settings { @@ -6441,6 +6492,7 @@ mod tests { let mut cache = CultCache::new(); cache.register_registry(TestEntries)?; cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; + cache.pull_all_backing_stores()?; cache.put( "settings:a", &Settings { @@ -6523,6 +6575,7 @@ mod tests { let mut cache = CultCache::new(); cache.register_entry_type::()?; cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; + cache.pull_all_backing_stores()?; cache.put( "primary", &Settings { @@ -8051,4 +8104,91 @@ mod tests { assert_eq!(fs::read(&path)?, before); Ok(()) } -} \ No newline at end of file + + /// A store already holding `Settings:"held"`, then a fresh cache attached to it that has not pulled. + fn unpulled_cache_over_populated_stores() -> Result<(tempfile::TempDir, Vec, Vec)> { + let temp = tempfile::tempdir()?; + let single = temp.path().join("single.cc"); + let redb = temp.path().join("redb.db"); + let held = Settings { theme: "held".to_string(), retries: 1 }; + let mut seeder = CultCache::new(); + seeder.register_registry(TestEntries)?; + seeder.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&single))?; + seeder.pull_all_backing_stores()?; + seeder.put("held", &held)?; + let mut seeder = CultCache::new(); + seeder.register_registry(TestEntries)?; + seeder.add_generic_backing_store(RedbMessagePackBackingStore::new(&redb)?)?; + seeder.pull_all_backing_stores()?; + seeder.put("held", &held)?; + + let mut over_single = CultCache::new(); + over_single.register_registry(TestEntries)?; + over_single.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&single))?; + let mut over_redb = CultCache::new(); + over_redb.register_registry(TestEntries)?; + over_redb.add_generic_backing_store(RedbMessagePackBackingStore::new(&redb)?)?; + Ok((temp, vec![over_single, over_redb], vec![single, redb])) + } + + #[test] + fn a_cache_that_never_pulled_refuses_every_write_and_removal() -> Result<()> { + let (_temp, caches, _paths) = unpulled_cache_over_populated_stores()?; + for mut cache in caches { + let fresh = Note { title: "t".to_string(), body: "b".to_string() }; + let (staged, _) = cache.prepare_entry("fresh", &fresh)?; + for error in [ + cache.put_prepared_batch(vec![staged.clone()]).unwrap_err(), + cache.put("fresh", &fresh).unwrap_err(), + cache.put_raw_envelope(staged.clone()).unwrap_err(), + cache.delete::("held").unwrap_err(), + ] { + assert!(error.downcast_ref::().is_some(), "{error:#}"); + } + assert!(cache.snapshot().is_empty()); + } + Ok(()) + } + + #[test] + fn a_batch_from_a_cache_that_never_pulled_cannot_wipe_the_store() -> Result<()> { + let (_temp, caches, paths) = unpulled_cache_over_populated_stores()?; + for (mut cache, path) in caches.into_iter().zip(paths) { + let (staged, _) = cache.prepare_entry("fresh", &Note { title: "t".to_string(), body: "b".to_string() })?; + let _ = cache.put_prepared_batch(vec![staged]); + let mut reader = CultCache::new(); + reader.register_registry(TestEntries)?; + if path.extension().is_some_and(|extension| extension == "db") { + reader.add_generic_backing_store(RedbMessagePackBackingStore::new(&path)?)?; + reader.pull_all_backing_stores()?; + } else { + reader.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path))?; + reader.pull_all_backing_stores()?; + } + reader.pull_all_backing_stores()?; + assert_eq!(reader.get_required::("held")?.theme, "held", "{path:?}"); + } + Ok(()) + } + + #[test] + fn pulling_lifts_the_gate_and_a_store_attached_later_closes_it_again() -> Result<()> { + let (temp, mut caches, _paths) = unpulled_cache_over_populated_stores()?; + let mut cache = caches.remove(0); + cache.pull_all_backing_stores()?; + cache.put("fresh", &Note { title: "t".to_string(), body: "b".to_string() })?; + assert_eq!(cache.get_required::("held")?.theme, "held"); + + let mut routed = CultCache::new(); + routed.register_registry(TestEntries)?; + let settings = Settings { theme: "t".to_string(), retries: 0 }; + routed.add_backing_store(SingleFileMessagePackBackingStore::new(temp.path().join("routed.cc")), [Settings::TYPE])?; + routed.pull_all_backing_stores()?; + routed.put("fresh", &settings)?; + routed.add_generic_backing_store(SingleFileMessagePackBackingStore::new(temp.path().join("other.cc")))?; + assert!(routed.put("again", &settings).unwrap_err().downcast_ref::().is_some()); + routed.pull_all_backing_stores()?; + routed.put("again", &settings)?; + Ok(()) + } +} diff --git a/packages/cultcache-rs/tests/slot_readonly.rs b/packages/cultcache-rs/tests/slot_readonly.rs index 0031e372..cea0853a 100644 --- a/packages/cultcache-rs/tests/slot_readonly.rs +++ b/packages/cultcache-rs/tests/slot_readonly.rs @@ -149,6 +149,7 @@ fn every_read_only_vector_decodes_marked_and_is_not_writable() { let mut cache = registered(); let dir = tempfile::tempdir().unwrap(); cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(dir.path().join("s.cc"))).unwrap(); + cache.pull_all_backing_stores().unwrap(); cache.put_raw_envelope(envelope("k", payload.clone())).unwrap(); assert_eq!(cache.get::("k").unwrap(), Some(constructed()), "{file}"); assert!(!cache.is_writable(UnionSurface::TYPE, "k").unwrap(), "{file}"); @@ -470,6 +471,7 @@ fn is_writable_is_false_for_a_type_with_no_home_store() { let mut cache = registered(); // One store, routed to SlotUnit only: UnionSurface has no home. cache.add_backing_store(SingleFileMessagePackBackingStore::new(dir.path().join("s.cc")), [SlotUnit::TYPE]).unwrap(); + cache.pull_all_backing_stores().unwrap(); assert!(cache.put("k", &constructed()).is_err()); assert!(!cache.is_writable(UnionSurface::TYPE, "k").unwrap()); assert!(cache.is_writable(SlotUnit::TYPE, "k").unwrap()); diff --git a/packages/cultcache-rs/tests/union_parity.rs b/packages/cultcache-rs/tests/union_parity.rs index 67f2b389..bc3aba6d 100644 --- a/packages/cultcache-rs/tests/union_parity.rs +++ b/packages/cultcache-rs/tests/union_parity.rs @@ -718,6 +718,7 @@ fn write_refused_past_limit() { let mut cache = CultCache::new(); cache.register_entry_type::().unwrap(); cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap(); + cache.pull_all_backing_stores().unwrap(); let at_limit = wrap_chain(126, boxed("0000000000f0", vec![])); assert_eq!(cache.put("at", &at_limit).unwrap(), at_limit); @@ -801,6 +802,7 @@ fn the_raw_doors_hold_the_bound_for_arms_maps_and_arrays() { cache.register_entry_type::().unwrap(); cache.register_entry_type::().unwrap(); cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap(); + cache.pull_all_backing_stores().unwrap(); cache.put_raw_envelope(raw(type_name, "at-raw", at.clone())).unwrap(); cache.put_prepared_batch(vec![raw(type_name, "at-batch", at.clone())]).unwrap(); let stored = cache.snapshot_envelopes(); From fbeb773d35412fb02ed29ca2fdb260a9c0c52686 Mon Sep 17 00:00:00 2001 From: Libby Date: Thu, 12 Nov 2026 08:45:52 +0000 Subject: [PATCH 53/93] Rust: batch writes merge onto the store (rs-batch-merges-onto-store) (#135) --- packages/cultcache-rs/CHANGELOG.md | 1 + packages/cultcache-rs/README.md | 7 +- packages/cultcache-rs/src/lib.rs | 167 ++++++++++++++++++++--------- 3 files changed, 121 insertions(+), 54 deletions(-) diff --git a/packages/cultcache-rs/CHANGELOG.md b/packages/cultcache-rs/CHANGELOG.md index c6d72492..b43c13c6 100644 --- a/packages/cultcache-rs/CHANGELOG.md +++ b/packages/cultcache-rs/CHANGELOG.md @@ -16,6 +16,7 @@ Semver: this is a breaking change in the 0.x line (0.3.x to 0.4.0). - A unit arm with slots (`[N, [extra...]]`) decodes as the unit arm and marks the record, where the derive refused it. - The registration map's value changes from the schema name to a private struct holding the schema name and the unread-slots check. - A cache whose last `pull_all_backing_stores` was refused (a tail, a payload past the nesting bound, a duplicate unique-index value, a retype conflict, a record from the wrong store) refuses every `put`, `put_envelope`, `put_raw_envelope`, `put_prepared_batch`, `update` and `delete` with `CultCacheLoadRefused` until a pull succeeds. Before, it stayed writable over an empty image, and the next batch write replaced the store with only the new records. +- `CacheBackingStore::push_all` writes the given entries onto the store as it is when the write lands, under the store's lock or transaction: a record under the same identity is replaced and no other record is removed. It used to replace everything the store held, so a cache whose image was stale (another writer had added a record since its pull) wiped that record with `put_prepared_batch`. `put_prepared_batch` now hands the store its staged records alone, as C# `CommitBatch` and Python `push_all` do; the lay-back of foreign records is gone because they are never rewritten. A custom `CacheBackingStore` must give `push_all` merge semantics. `push_all` reads the store, so through a dangling link it is refused like `push` and no longer replaces the link; a soft flush is unchanged. - A cache with a backing store also refuses every write and removal, with `CultCacheLoadRefused`, until it has pulled: a cache that never pulled, or that gained a store since its last pull, holds no image of its stores, and a batch from it replaced a populated store with only the new records. `is_writable` answers false meanwhile. A cache with no store needs no pull. Callers that attach a store and write (cultmesh nodes created with `pull_on_start: false`) must call `pull_all_backing_stores` first. - `decode_payload`, `put_raw_envelope` and prepared batches refuse a payload with bytes after its first MessagePack value (`payload has {n} bytes after its first MessagePack value`), by count; the tail is never walked or quoted. They used to read the first value and keep or drop the tail, so a record with a stray tail was served and rewritten without it. A store holding such a record (single-file, redb, `load_soa`) is refused at load, naming the type, the key and the count, and stays unreadable until the record is repaired; the same check now runs on every record at load, as the C# and Python runtimes do at open. The nesting bound is part of that check, so a stored payload nested past it is refused at load too, where it used to load and refuse on read. - A single-file store written as `cultcache.store.v3` is read and written: a store the C# runtime marked v3 (it holds a record with an element id: an owned list element with a `[CultElementId]` member) keeps its header here, and is carried through every rewrite with its ids; this runtime never drops the mark. A runtime from before this change refuses a v3 store by name. Rolling back to an older release after a store has been written as v3 is one-way: there is no carry-back, so restore from a copy taken before upgrading. diff --git a/packages/cultcache-rs/README.md b/packages/cultcache-rs/README.md index ed9701d5..da4185e2 100644 --- a/packages/cultcache-rs/README.md +++ b/packages/cultcache-rs/README.md @@ -384,10 +384,9 @@ an empty store. Any other failure to reach it, such as a symlink loop, a file where a parent directory should be, or a missing permission, is an I/O error. A store file that exists and holds zero bytes is `Undecodable`: no CultCache writer leaves one. A symbolic link whose target is gone is not a missing store: -reading it is an I/O error, so a read-then-write (`push`, `delete`, the exchanges) -fails before writing. Writes do not yet follow links: a write through a live link, -and a `push_all` through a dangling one, replace the link with a regular file and -leave its target alone. A refusal names no +reading it is an I/O error, so a read-then-write (`push`, `push_all`, `delete`, the exchanges) +fails before writing. Writes do not yet follow links: a write through a live link +replaces the link with a regular file and leaves its target alone. A refusal names no value read from the file; it names a record only by its key and schema id, and a header only when it has the shape `cultcache.store.v`. diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index 47a3dd18..678ff4d6 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -1253,12 +1253,14 @@ pub trait CacheBackingStore: Send { fn push(&mut self, entry: &CultCacheEnvelope) -> Result<()>; fn delete(&mut self, entry: &CultCacheEnvelope) -> Result<()>; - /// Replaces everything the store holds with `entries` in one atomic step: - /// on `Err` the store still holds exactly what it held before. A cache - /// batch (`put_prepared_batch`) is all-or-nothing only through this. There - /// is no default, because a replace assembled from `delete` and `push` - /// calls leaves the store emptied or partial when one of them fails; a - /// store without an atomic replace does not compile: + /// Writes `entries` onto the store as it is when the write lands, in one + /// atomic step: a record under the same identity is replaced and no other + /// record is removed or changed, so a writer whose image is stale cannot + /// drop a record it never saw. On `Err` the store still holds exactly what + /// it held before. A cache batch (`put_prepared_batch`) is all-or-nothing + /// only through this. There is no default, because a write assembled from + /// `push` calls leaves the store partial when one of them fails; a store + /// without an atomic write does not compile: /// /// ```compile_fail,E0046 /// use cultcache_rs::{CacheBackingStore, CultCacheEnvelope}; @@ -1675,13 +1677,7 @@ impl SingleFileMessagePackBackingStore { // payloads this store passes through may hold ids), one that is gone or legacy is written v1. The file is read by // the same reader as an open, so a store this runtime cannot read completely, a variant included, is refused // and never overwritten. - let disk = match self.read_store_unlocked() { - // A dangling link holds no store, so a whole-file replace has no header to keep and replaces the link (R3 pins - // this). The only NotFound the reader returns is that one: a merging writer has already refused it when it - // read the current entries. - Err(error) if io_not_found(&error) => DiskStore::absent(), - other => other?, - }; + let disk = self.read_store_unlocked()?; let entries = settle_one_record_per_key(entries, &disk)?; let bytes = encode_store_snapshot(&entries, &disk) .and_then(|snapshot| rmp_serde::to_vec(&snapshot).context("failed to encode MessagePack")) @@ -1962,14 +1958,18 @@ impl CacheBackingStore for SingleFileMessagePackBackingStore { } // Of entries sharing a key and a schema id the last one given is the record, whatever their type names (a type - // renamed under a stable id); a second schema id under a key reaches the commit guard. + // renamed under a stable id); it replaces the held record under that key and schema id. A second schema id + // under a key reaches the commit guard, which judges the merged store. let mut by_id = BTreeMap::<(String, String), &CultCacheEnvelope>::new(); for entry in entries { by_id.insert((schema_identity(entry).to_string(), entry.key.clone()), entry); } - let mut entries: Vec = by_id.into_values().cloned().collect(); - entries.sort_by_key(entry_id); - self.write_all_unlocked(&entries) + let mut merged = self.read_all_unlocked()?; + let replaced: BTreeSet<_> = by_id.values().map(|entry| entry_id(entry)).collect(); + merged.retain(|held| !replaced.contains(&entry_id(held))); + merged.extend(by_id.into_values().cloned()); + merged.sort_by_key(entry_id); + self.write_all_unlocked(&merged) }) } } @@ -2757,13 +2757,6 @@ impl CacheBackingStore for OwnedRedbMessagePackBackingStore { let write = self.inner.database.begin_write()?; { let mut table = write.open_table(REDB_ENVELOPES)?; - let keys = table - .iter()? - .map(|row| Ok(row?.0.value().to_vec())) - .collect::>>()?; - for key in keys { - table.remove(key.as_slice())?; - } for entry in entries { insert_redb_entry(&mut table, entry)?; } @@ -2899,13 +2892,6 @@ impl CacheBackingStore for RedbMessagePackBackingStore { let write = database.begin_write()?; { let mut table = write.open_table(REDB_ENVELOPES)?; - let keys = table - .iter()? - .map(|row| Ok(row?.0.value().to_vec())) - .collect::>>()?; - for key in keys { - table.remove(key.as_slice())?; - } for entry in entries { insert_redb_entry(&mut table, entry)?; } @@ -3602,18 +3588,12 @@ impl CultCache { } let change = self.judge_unique(&staged.iter().collect::>())?; if let Some(store_index) = store_index { - // The home store receives its own records only: those it already holds in this cache plus the staged ones, and - // the records it carries that no registered type claims: push_all replaces the store's records, so a foreign - // record is laid back as the store read it. - let snapshot: Vec<_> = next - .values() - .filter(|entry| self.resolve_route_index(&entry.r#type) == Some(store_index)) - .chain(self.foreign.iter().filter(|(home, _)| *home == store_index).map(|(_, entry)| entry)) - .cloned() - .collect(); + // The home store is handed the staged records alone and applies them onto what it holds now. + // A key staged twice lands as its last record, whatever the store. + let landing: BTreeMap<_, _> = staged.iter().map(|entry| (entry_id(entry), entry.clone())).collect(); self.stores[store_index] .store - .push_all(&snapshot, PushAllOptions::default())?; + .push_all(&landing.into_values().collect::>(), PushAllOptions::default())?; } self.entries = next; change.apply(&mut self.index_holders); @@ -4324,12 +4304,6 @@ fn release_lock(lock: File, result: Result) -> Result { result } -fn io_not_found(error: &anyhow::Error) -> bool { - error - .chain() - .any(|cause| cause.downcast_ref::().is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)) -} - /// True only when nothing is at `path`; a symbolic link, dangling or not, is something. /// A path that cannot be inspected (a file where a directory should be, no permission) /// is an error, never absent. @@ -5400,6 +5374,12 @@ mod tests { Ok(()) } + fn io_not_found(error: &anyhow::Error) -> bool { + error + .chain() + .any(|cause| cause.downcast_ref::().is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)) + } + fn test_envelope(r#type: &str, key: &str, payload: &[u8]) -> CultCacheEnvelope { CultCacheEnvelope { key: key.to_string(), @@ -7980,15 +7960,17 @@ mod tests { #[cfg(unix)] #[test] - fn r3_decides_resolve_or_refuse_a_push_all_through_a_dangling_link_replaces_the_link() -> Result<()> { + fn a_push_all_through_a_dangling_link_is_refused_like_push_and_writes_nothing() -> Result<()> { let temp = tempfile::tempdir()?; let volume = temp.path().join("volume"); fs::create_dir(&volume)?; let link = temp.path().join("store.cc"); std::os::unix::fs::symlink(volume.join("store.cc"), &link)?; - SingleFileMessagePackBackingStore::new(&link).push_all(&[snapshot_envelope("a", b"1")], PushAllOptions::default())?; - assert!(fs::symlink_metadata(&link)?.file_type().is_file(), "the link was replaced by a regular file"); + let mut store = SingleFileMessagePackBackingStore::new(&link); + assert!(store.push_all(&[snapshot_envelope("a", b"1")], PushAllOptions::default()).is_err()); + assert!(store.push(&snapshot_envelope("a", b"1")).is_err()); + assert!(fs::symlink_metadata(&link)?.file_type().is_symlink(), "the link is left as it was"); assert_eq!(fs::read_dir(&volume)?.count(), 0, "nothing reached the link's volume"); Ok(()) } @@ -8191,4 +8173,89 @@ mod tests { routed.put("again", &settings)?; Ok(()) } + + #[test] + fn a_stale_cache_batch_keeps_the_records_another_writer_added() -> Result<()> { + let temp = tempfile::tempdir()?; + let single = temp.path().join("single.cc"); + let redb = temp.path().join("redb.db"); + let open = |path: &Path| -> Result { + let mut cache = CultCache::new(); + cache.register_registry(TestEntries)?; + if path.extension().is_some_and(|extension| extension == "db") { + cache.add_generic_backing_store(RedbMessagePackBackingStore::new(path)?)?; + } else { + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(path))?; + } + cache.pull_all_backing_stores()?; + Ok(cache) + }; + for path in [&single, &redb] { + let mut seed = open(path)?; + seed.put("held", &Settings { theme: "held".to_string(), retries: 1 })?; + let mut stale = open(path)?; + let mut other = open(path)?; + other.put("held", &Settings { theme: "other-newer".to_string(), retries: 3 })?; + other.put("added", &Settings { theme: "other".to_string(), retries: 2 })?; + let (staged, _) = stale.prepare_entry("mine", &Note { title: "t".to_string(), body: "b".to_string() })?; + stale.put_prepared_batch(vec![staged])?; + let reader = open(path)?; + assert_eq!(reader.get_required::("added")?.theme, "other", "{path:?}"); + assert_eq!(reader.get_required::("held")?.theme, "other-newer", "a record another writer updated is not reverted"); + assert_eq!(reader.get_required::("mine")?.title, "t", "{path:?}"); + } + Ok(()) + } + + #[test] + fn every_store_merges_push_all_and_lands_the_last_of_a_repeated_key() -> Result<()> { + let temp = tempfile::tempdir()?; + let stores: Vec> = vec![ + Box::new(SingleFileMessagePackBackingStore::new(temp.path().join("single.cc"))), + Box::new(RedbMessagePackBackingStore::new(temp.path().join("redb.db"))?), + Box::new(OwnedRedbMessagePackBackingStore::new(temp.path().join("owned.db"))?), + ]; + for mut store in stores { + store.push(&test_envelope("pushall.merge", "kept", b"kept"))?; + store.push_all(&[test_envelope("pushall.merge", "new", b"new")], PushAllOptions::default())?; + let keys: Vec<_> = store.pull_all()?.into_iter().map(|entry| entry.key).collect(); + assert_eq!(keys, ["kept", "new"]); + } + // A batch naming a key twice lands the last, in the single-file and the redb stores alike. + for path in [temp.path().join("dup.cc"), temp.path().join("dup.db")] { + let mut cache = CultCache::new(); + cache.register_registry(TestEntries)?; + if path.extension().is_some_and(|extension| extension == "db") { + cache.add_generic_backing_store(RedbMessagePackBackingStore::new(&path)?)?; + } else { + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path))?; + } + cache.pull_all_backing_stores()?; + let (first, _) = cache.prepare_entry("k", &Settings { theme: "first".to_string(), retries: 1 })?; + let (last, _) = cache.prepare_entry("k", &Settings { theme: "last".to_string(), retries: 2 })?; + cache.put_prepared_batch(vec![first, last])?; + assert_eq!(cache.get_required::("k")?.theme, "last"); + } + Ok(()) + } + + #[test] + fn push_all_replaces_a_record_held_under_another_schema_id_as_push_does() -> Result<()> { + let temp = tempfile::tempdir()?; + let mut store = SingleFileMessagePackBackingStore::new(temp.path().join("store.cc")); + let mut held = snapshot_envelope("k", b"old"); + held.schema_id = Some("legacy.id".to_string()); + store.push(&held)?; + let mut replacement = snapshot_envelope("k", b"new"); + replacement.schema_id = None; + let mut by_push = store.clone(); + by_push.push(&replacement)?; + assert_eq!(store.pull_all()?.len(), 1); + store.push(&held)?; + store.push_all(&[replacement.clone()], PushAllOptions::default())?; + let held_now = store.pull_all()?; + assert_eq!(held_now.len(), 1, "one record under the key"); + assert_eq!(held_now[0].payload, replacement.payload); + Ok(()) + } } From b098d5b7da5a884fc1af5488aaccdc5c1931812f Mon Sep 17 00:00:00 2001 From: Libby Date: Thu, 12 Nov 2026 09:47:33 +0000 Subject: [PATCH 54/93] TypeScript: pushAll writes onto the store instead of replacing it (ts-flush-replaces-store) (#136) --- packages/cultcache-ts/CHANGELOG.md | 1 + packages/cultcache-ts/README.md | 2 +- packages/cultcache-ts/src/cult-cache.ts | 7 +++++ .../single-file-messagepack-backing-store.ts | 22 +++++++------ packages/cultcache-ts/test/cult-cache.test.ts | 31 ++++++++++++++++--- packages/cultcache-ts/test/store-file.test.ts | 11 +++++-- packages/cultmesh-ts/src/index.ts | 2 +- packages/cultmesh-ts/test/cultmesh.test.ts | 30 +++++++++++++++++- 8 files changed, 88 insertions(+), 18 deletions(-) diff --git a/packages/cultcache-ts/CHANGELOG.md b/packages/cultcache-ts/CHANGELOG.md index 2da49fe9..dcbfd12e 100644 --- a/packages/cultcache-ts/CHANGELOG.md +++ b/packages/cultcache-ts/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following ### Breaking +- `SingleFileMessagePackBackingStore.pushAll` writes the given entries onto the store as it is under the lock, as `push` does: a record under the same key is replaced and no other is removed. It used to replace the whole file with the entries, so `CultMesh.flush()` from a cache that held less than the store (a node created with `pullOnStart: false`, or one whose image was stale) wiped the records it never read. A caller that relied on `pushAll` to reset a store removes the file first. A soft flush and the dangling-link case are unchanged. `CultCache.storedSnapshot()` is the image as the stores hold it (a global loaded from a legacy key under that key) and is what `CultMesh.flush` persists, so a flush no longer leaves such a global under two keys; `snapshot()` is unchanged. - A single-file store marked `cultcache.store.v3` (it holds an element id, written by the C# runtime) is read, and a rewrite keeps the mark and the ids; this runtime does not decide when a document carries ids. A runtime from before this change refuses a v3 store by name, so rolling back to an older release after a store has been written as v3 is one-way: there is no carry-back, so restore from a copy taken before upgrading. - `indexes` are unique, as C# `[CultIndex]`: a write or a load that leaves two records of a type holding one non-blank value under one alias is refused naming both keys and the alias, and writes nothing. Index text is the C# canonical diff --git a/packages/cultcache-ts/README.md b/packages/cultcache-ts/README.md index a081cd92..625d28dd 100644 --- a/packages/cultcache-ts/README.md +++ b/packages/cultcache-ts/README.md @@ -174,7 +174,7 @@ const settings = cache.getRequiredGlobal(settingsDocument); - `updateGlobal(...)` - `delete(...)` - `deleteGlobal(...)` -- `snapshot()` +- `snapshot()` (the logical image) and `storedSnapshot()` (the image as the stores hold it, for a whole flush) - `getEnvelope(...)` - `getRequiredEnvelope(...)` - `getGlobalEnvelope(...)` diff --git a/packages/cultcache-ts/src/cult-cache.ts b/packages/cultcache-ts/src/cult-cache.ts index 6cf7a8cb..a88bd66d 100644 --- a/packages/cultcache-ts/src/cult-cache.ts +++ b/packages/cultcache-ts/src/cult-cache.ts @@ -636,6 +636,13 @@ export class CultCache { return [...this.#hydrated.entries.values()].map((entry) => this.#toEnvelope(entry)); } + // The image as the stores hold it, for persisting it whole (a flush): a global loaded from a legacy key is handed out under + // that key until its first write retires it, so a flush cannot leave the store holding it under two keys. `snapshot` is the + // logical image and always names a global by CultCache.GLOBAL_KEY. + storedSnapshot(): CultCacheEnvelope[] { + return [...this.#hydrated.entries.values()].map((entry) => this.#hydrated.legacyGlobals.get(entry.type) ?? this.#toEnvelope(entry)); + } + #registerNow(definition: AnyCultCacheDocumentDefinition): void { // Every registration refusal is a SchemaConflictError naming the new definition's schema id and both schema names. const refuse = (holder: RegisteredDefinition, claimed: CultCacheSchemaCatalogEntry, what: string) => diff --git a/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts b/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts index 5dfcfc74..298f1bee 100644 --- a/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts +++ b/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts @@ -82,10 +82,12 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore { async pushAll(entries: CultCacheEnvelope[], options: PushAllOptions = {}): Promise { await this.#enqueue(async () => { - // A flush of the whole store writes the header the file on disk carries, read now: a file marked for element ids - // stays marked, and one that is not (or is gone or legacy) is written unmarked. The file is read by the same - // reader as `pullAll`, so one it would refuse (not exactly one store, a variant, a body it cannot decode) is refused - // and left as it is. A soft flush asks that reader too: nothing at the path is written, a store is left alone. + // A flush writes `entries` onto the store as it is under the lock, the way a push does: a record under the same key + // is replaced and no other is removed, so a cache whose image is stale (or empty, never pulled) cannot drop what + // another writer added. The store is read by the same reader as `pullAll`, so one it would refuse (not exactly one + // store, a variant, a body it cannot decode) is refused and left as it is. A soft flush asks that reader too: nothing + // at the path is written, a store is left alone. + let held: CultCacheEnvelope[] = []; try { const disk = this.#readDisk(); if (options.soft && !disk.absent) { @@ -93,10 +95,11 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore { } this.#format = disk.format; + held = disk.envelopes; } catch (error) { - // Only a dangling link reaches here with ENOENT: it holds no store, so a whole-store flush has no header to keep and - // replaces the link (R3 pins this). A soft flush does not replace what it cannot read, and every merging writer - // has already refused the link when it read the current entries. + // Only a dangling link reaches here with ENOENT: it holds no store, so a flush has nothing to merge with and no + // header to keep, and replaces the link (R3 pins this). A soft flush does not replace what it cannot read, and every + // merging writer has already refused the link when it read the current entries. if (options.soft || (error as NodeJS.ErrnoException).code !== "ENOENT") { throw error; } @@ -104,9 +107,10 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore { this.#format = STORE_FORMAT_VERSION; } - requireOneTypePerKey(this.filePath, entries, []); // A key is held once: of same-type entries sharing a key in one batch, the last is kept. - await this.#writeAll([...new Map(entries.map((entry) => [entry.key, entry])).values()], []); + const supplied = [...new Map(entries.map((entry) => [entry.key, entry])).values()]; + requireOneTypePerKey(this.filePath, entries, held); + await this.#writeAll(supplied, held); }); } diff --git a/packages/cultcache-ts/test/cult-cache.test.ts b/packages/cultcache-ts/test/cult-cache.test.ts index b575170f..2636a9d3 100644 --- a/packages/cultcache-ts/test/cult-cache.test.ts +++ b/packages/cultcache-ts/test/cult-cache.test.ts @@ -1019,6 +1019,7 @@ test("legacyKeyGlobalLoadsWithoutWritingAndFirstWriteLeavesOneGlobal", async () assert.deepEqual(reloaded.getRequiredGlobal(settingsDocument), { t: "new" }); // Deleting an adopted global removes the legacy record. + await rm(storePath); await new SingleFileMessagePackBackingStore(storePath).pushAll([legacy]); const deleting = build(); await deleting.pullAllBackingStores(); @@ -1026,10 +1027,30 @@ test("legacyKeyGlobalLoadsWithoutWritingAndFirstWriteLeavesOneGlobal", async () assert.deepEqual(await new SingleFileMessagePackBackingStore(storePath).pullAll(), []); // Two globals of one type on disk, under any keys, are refused. + await rm(storePath); await new SingleFileMessagePackBackingStore(storePath).pushAll([legacy, { ...legacy, key: CultCache.GLOBAL_KEY }]); await assert.rejects(build().pullAllBackingStores(), /has multiple persisted entries/u); }); +test("a flush of a cache that loaded a legacy-key global leaves the store reopenable", async () => { + const settingsDocument = defineDocumentType({ type: "settings", schema: z.object({ t: z.string() }), global: true }); + const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-legacy-flush-")), "generic.cc"); + const store = new SingleFileMessagePackBackingStore(storePath); + const build = () => CultCache.builder().withRegistry(defineDocumentRegistry(settingsDocument)).withGenericStore(store).build(); + const seed = CultCache.builder().withRegistry(defineDocumentRegistry(settingsDocument)).build(); + await seed.putGlobal(settingsDocument, { t: "old" }); + await store.push({ ...seed.getRequiredEnvelope(settingsDocument, CultCache.GLOBAL_KEY), key: "legacy" }); + + const cache = build(); + await cache.pullAllBackingStores(); + assert.equal(cache.snapshot()[0]!.key, CultCache.GLOBAL_KEY, "the logical image names the global by the global key"); + await store.pushAll(cache.storedSnapshot()); + assert.deepEqual((await store.pullAll()).map((entry) => entry.key), ["legacy"], "the flush wrote the record under the key the store holds it"); + const reopened = build(); + await reopened.pullAllBackingStores(); + assert.deepEqual(reopened.getRequiredGlobal(settingsDocument), { t: "old" }); +}); + test("concurrentPutAndAttachCannotLandRecordInNonHomeStore", async () => { const settingsDocument = defineDocumentType({ type: "settings", schema: z.object({ t: z.string() }) }); const tempDir = await mkdtemp(join(tmpdir(), "cultcache-put-attach-race-")); @@ -2261,7 +2282,7 @@ test("a stamp this runtime reads, followed by bytes that are not UTF-8, gets the } }); -test("pushAll leaves a store it reads alone when soft, and replaces one it reads otherwise", async () => { +test("pushAll leaves a store it reads alone when soft, and writes onto one it reads otherwise", async () => { const vector = await readFile(resolve(cultLibRoot, "tests", "vectors", "document-variants-c2a", "v3-base.msgpack")); const envelope = { key: "probe", type: "probe", payload: documentPayloadFromBytes(new Uint8Array([0x80])), storedAt: "2026-10-22T00:00:00Z" }; const existing = join(await mkdtemp(join(tmpdir(), "cultcache-pushall-soft-")), "store.msgpack"); @@ -2273,7 +2294,7 @@ test("pushAll leaves a store it reads alone when soft, and replaces one it reads const store = new SingleFileMessagePackBackingStore(own); await store.pushAll([envelope]); await store.pushAll([{ ...envelope, key: "second" }]); - assert.deepEqual((await store.pullAll()).map((entry) => entry.key), ["second"]); + assert.deepEqual((await store.pullAll()).map((entry) => entry.key), ["probe", "second"]); }); test("SingleFileMessagePackBackingStore reads only a missing store as empty", async () => { @@ -2575,7 +2596,7 @@ test("SingleFileMessagePackBackingStore pushAll refuses a store whose header it // A file is replaced by a rewrite exactly when this runtime's own reader opens it: one verdict per file, asked by pullAll, // pushAll, soft pushAll, push and delete alike; a soft flush leaves the files that read as they are. The bytes and every runtime's verdict: tests/vectors/document-variants-c2a/readability. -test("SingleFileMessagePackBackingStore replaces a file exactly when it reads", async () => { +test("SingleFileMessagePackBackingStore rewrites a file exactly when it reads", async () => { const root = join(c2aVectors, "readability"); const rows = (await readFile(join(root, "manifest.txt"), "utf8")) .split(/\r?\n/u) @@ -2612,6 +2633,7 @@ test("SingleFileMessagePackBackingStore replaces a file exactly when it reads", ? store.push(envelopes[0]!) : store.delete(envelopes[0]!); if (reads) { + const heldBefore = operation === "pushAll" ? (await new SingleFileMessagePackBackingStore(file).pullAll()).map((entry) => entry.key) : []; await run(); if (operation === "pullAll") { continue; @@ -2628,7 +2650,8 @@ test("SingleFileMessagePackBackingStore replaces a file exactly when it reads", assert.equal((decode(await readFile(file)) as unknown[])[0], vector.includes("v3") ? "cultcache.store.v3" : "cultcache.store.v1", `${vector} ${operation}`); const keys = (await new SingleFileMessagePackBackingStore(file).pullAll()).map((entry) => entry.key); if (operation === "pushAll") { - assert.deepEqual(keys, envelopes.map((entry) => entry.key).sort(), `${vector} ${operation}`); + assert.deepEqual(envelopes.map((entry) => entry.key).filter((key) => !keys.includes(key)), [], `${vector} ${operation}: every written key is held`); + assert.deepEqual(heldBefore.filter((key) => !keys.includes(key)), [], `${vector} ${operation}: nothing else is removed`); } else if (operation === "push") { assert.ok(keys.includes(envelopes[0]!.key), `${vector} ${operation}`); } else { diff --git a/packages/cultcache-ts/test/store-file.test.ts b/packages/cultcache-ts/test/store-file.test.ts index eb41b77b..39436e30 100644 --- a/packages/cultcache-ts/test/store-file.test.ts +++ b/packages/cultcache-ts/test/store-file.test.ts @@ -670,7 +670,7 @@ test("delete removes only the record at that type and key", async () => { } }); -test("pushAll replaces the whole store, and a soft pushAll leaves an existing store alone", async () => { +test("pushAll writes onto the store as it is: it replaces by key and removes nothing, and a soft pushAll leaves an existing store alone", async () => { const directory = await scratch(); try { const path = join(directory, "store.cc"); @@ -681,7 +681,14 @@ test("pushAll replaces the whole store, and a soft pushAll leaves an existing st await store.pushAll([entryOf("b", "t", 2)], { soft: true }); assert.deepEqual([...(await readFile(path))], [...before], "soft leaves an existing store byte for byte"); await store.pushAll([entryOf("b", "t", 2)]); - assert.deepEqual(await snapshotOf(store), ["t/b:2"]); + assert.deepEqual(await snapshotOf(store), ["t/a:1", "t/b:2"], "a record the batch does not name stays"); + await store.pushAll([entryOf("a", "t", 7)]); + assert.deepEqual(await snapshotOf(store), ["t/a:7", "t/b:2"], "a record the batch names is replaced"); + await store.pushAll([]); + assert.deepEqual(await snapshotOf(store), ["t/a:7", "t/b:2"], "an empty flush from a cache that holds nothing wipes nothing"); + const before2 = [...(await readFile(path))]; + await assert.rejects(() => store.pushAll([entryOf("a", "u", 1)]), SchemaConflictError); + assert.deepEqual([...(await readFile(path))], before2, "a batch that retypes a held key is refused and the file is unchanged"); } finally { await rm(directory, { recursive: true, force: true }); } diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index 2f734cfd..e6f71e21 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -3933,7 +3933,7 @@ export class CultMeshNode { } public async flush(soft = false): Promise { - await this.store.pushAll?.(this.cache.snapshot(), { soft }); + await this.store.pushAll?.(this.cache.storedSnapshot(), { soft }); } } diff --git a/packages/cultmesh-ts/test/cultmesh.test.ts b/packages/cultmesh-ts/test/cultmesh.test.ts index 10de21b9..341a35b3 100644 --- a/packages/cultmesh-ts/test/cultmesh.test.ts +++ b/packages/cultmesh-ts/test/cultmesh.test.ts @@ -6,7 +6,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { decodeWireValue, encodeWireValue } from "@gamecult/cultnet-ts/wire"; import { z } from "zod"; -import { SchemaConflictError, defineDocumentType, documentPayloadBytes, documentPayloadFromBytes } from "@gamecult/cultcache-ts"; +import { SchemaConflictError, SingleFileMessagePackBackingStore, defineDocumentType, documentPayloadBytes, documentPayloadFromBytes } from "@gamecult/cultcache-ts"; import { CultNetDocumentRegistry, CultNetPeer, @@ -109,6 +109,34 @@ test("CultMesh TS opens a durable local node and registers document bindings", a assert.ok(reopened.documents.getBySchemaId("cultmesh.note.v0")); }); +test("CultMesh TS flush from a node that never pulled leaves the records the store holds", async () => { + const filePath = join(await mkdtemp(join(tmpdir(), "cultmesh-ts-flush-")), "node.ccmp"); + const first = await CultMesh.startNode(filePath, { documents: [noteDocument] }); + await first.put(noteDocument, "note:held", { noteId: "note:held", body: "already in the store" }); + await first.flush(); + + const unpulled = await CultMesh.startNode(filePath, { documents: [noteDocument], pullOnStart: false }); + await unpulled.flush(); + + const reopened = await CultMesh.startNode(filePath, { documents: [noteDocument] }); + assert.equal(reopened.getRequired(noteDocument, "note:held").body, "already in the store"); +}); + +test("CultMesh TS flush of a node that loaded a legacy-key global leaves the store reopenable", async () => { + const settings = defineDocumentType({ type: "cultmesh.settings", schema: z.object({ theme: z.string() }), global: true }); + const filePath = join(await mkdtemp(join(tmpdir(), "cultmesh-ts-legacy-flush-")), "node.ccmp"); + const seed = await CultMesh.startNode(join(await mkdtemp(join(tmpdir(), "cultmesh-ts-seed-")), "seed.ccmp"), { documents: [settings] }); + await seed.cache.putGlobal(settings, { theme: "iron" }); + const legacy = { ...seed.cache.getRequiredEnvelope(settings, "__global__"), key: "legacy" }; + await new SingleFileMessagePackBackingStore(filePath).push(legacy); + + const node = await CultMesh.startNode(filePath, { documents: [settings] }); + await node.flush(); + + const reopened = await CultMesh.startNode(filePath, { documents: [settings] }); + assert.deepEqual(reopened.cache.getRequiredGlobal(settings), { theme: "iron" }); +}); + test("CultMesh TS document handles hide local cache plumbing behind typed reactive reads", async () => { const filePath = join(await mkdtemp(join(tmpdir(), "cultmesh-ts-doc-")), "node.ccmp"); const node = await CultMesh.startNode(filePath, { From 2fd80c274b5f8691d360297f5cf95f2aeb2488e8 Mon Sep 17 00:00:00 2001 From: Libby Date: Thu, 12 Nov 2026 09:47:38 +0000 Subject: [PATCH 55/93] Rust mesh: a raw snapshot pull is one cache commit (mesh-snapshot-atomic) (#134) --- packages/cultcache-rs/CHANGELOG.md | 2 +- packages/cultmesh-rs/src/lib.rs | 40 ++++++++++++++++++++++-------- 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/packages/cultcache-rs/CHANGELOG.md b/packages/cultcache-rs/CHANGELOG.md index b43c13c6..e1fad67d 100644 --- a/packages/cultcache-rs/CHANGELOG.md +++ b/packages/cultcache-rs/CHANGELOG.md @@ -11,7 +11,7 @@ only in git history. Semver: this is a breaking change in the 0.x line (0.3.x to 0.4.0). - A record whose payload carries unread slots is read-only. A payload with a slot past its type's declared slots, at any depth (record, nested record, list element, union arm, unit arm), still decodes, and every write or removal of its key is now refused before any store is touched: `put`, `put_envelope`, `put_raw_envelope`, `put_prepared_batch` (the whole batch) and `delete`, so `update` too. The refusal is `SchemaConflictError` with the new `unread_slots`; its message names the type and both slot counts and never a slot value. Holders of such a record used to overwrite it and drop the extra slots silently. The payload nesting refusal still runs first. A held payload this build cannot read at all (an arm it lacks from a newer writer, or a corrupt record) is read-only too: every write or removal is refused with an error naming the type and key (a newer writer's record is read with that build; a corrupt one is restored from a backup or removed at the store itself, as no cache call removes it), and `is_writable` answers false. `load_soa` is a write of every held key it omits or changes, so it refuses to drop or replace a read-only record. `is_writable` also answers false for a key a store carries as a foreign record and for a type other than the one holding the key, as the writes do. Each write over a held key decodes the held payload once to ask (about 40% of a `put` of a 200,000-element record, measured in release); keys nothing holds pay nothing. -- cultmesh-rs `pull_rudp_catalog_snapshot` applies documents one by one with `put_raw_envelope`, so a snapshot document for a key the node holds read-only ends the pull at that document and the documents before it stay applied. C# applies a raw snapshot as one cache commit, so there a refusal applies nothing: this is a divergence, owed by follow-up `mesh-snapshot-atomic` (stage the pull through `put_prepared_batch`). +- cultmesh-rs `pull_rudp_catalog_snapshot` applies a snapshot as one cache commit (`put_prepared_batch`), as C# does: a snapshot document for a key the node holds read-only, or any other refusal, applies nothing. It used to apply document by document, so a refusal ended the pull with the earlier documents kept. A snapshot whose types route to different backing stores is now refused whole, as a batch cannot span stores. - `SchemaConflictError` gains the public field `unread_slots: Option`; code that builds it with a struct literal must set it (`None`). - A unit arm with slots (`[N, [extra...]]`) decodes as the unit arm and marks the record, where the derive refused it. - The registration map's value changes from the schema name to a private struct holding the schema name and the unread-slots check. diff --git a/packages/cultmesh-rs/src/lib.rs b/packages/cultmesh-rs/src/lib.rs index 18a02fcb..310629e3 100644 --- a/packages/cultmesh-rs/src/lib.rs +++ b/packages/cultmesh-rs/src/lib.rs @@ -735,30 +735,33 @@ impl CultMeshNode { self.apply_raw_snapshot_documents(documents) } - /// Applies each snapshot document of a registered schema with `put_raw_envelope`. The first refusal ends the pull - /// and the documents before it stay applied; a key this node holds read-only (unread slots from a newer peer, or a - /// payload this build cannot read) is refused like any other. C# applies a raw snapshot as one cache commit, so a - /// refusal there applies nothing: this is a divergence, owed by follow-up mesh-snapshot-atomic (stage the pull through - /// `put_prepared_batch`). + /// Applies the snapshot documents as one cache commit, as C# does: a refusal (a key this node holds read-only because + /// of unread slots from a newer peer or a payload this build cannot read, or any other the cache judges) applies + /// nothing. A schema id the registry does not know is skipped, except one with a `.vN` suffix, which is taken as + /// registered and refuses the commit when its type is not (C# throws on an unknown schema too). The commit lands in + /// one backing store, so a snapshot spanning stores is refused whole. fn apply_raw_snapshot_documents( &mut self, documents: Vec, ) -> Result { - let mut applied = 0usize; + let mut staged = Vec::new(); for document in documents { let Some(document_type) = registered_document_type_for_schema(&self.documents, &document.schema_id) else { continue; }; - self.cache.put_raw_envelope(CultCacheEnvelope { + staged.push(CultCacheEnvelope { key: document.record_key, r#type: document_type, payload: document.payload, stored_at: document.stored_at, schema_id: Some(document.schema_id), - })?; - applied += 1; + }); + } + let applied = staged.len(); + if applied > 0 { + self.cache.put_prepared_batch(staged)?; } Ok(applied) } @@ -1226,7 +1229,7 @@ mod tests { } #[test] - fn a_snapshot_document_for_a_read_only_key_ends_the_pull_unlike_csharp_which_applies_nothing() -> Result<()> { + fn a_snapshot_document_for_a_read_only_key_refuses_the_whole_pull_as_csharp_does() -> Result<()> { let temp = tempfile::tempdir()?; let mut node = CultMesh::create_node(temp.path().join("m.cc"), TestDocuments, Default::default())?; // A newer peer's note: one slot past the declared one. @@ -1246,12 +1249,27 @@ mod tests { assert_eq!(conflict.record_key, "held"); assert!(conflict.unread_slots.is_some()); assert!(!error.to_string().contains("extra slot")); - assert_eq!(node.get_required::("before")?.body, "b"); + assert!(node.get::("before")?.is_none()); assert_eq!(node.get_required::("held")?.body, "newer body"); assert!(node.get::("after")?.is_none()); Ok(()) } + #[test] + fn a_snapshot_applied_to_a_stale_node_keeps_the_records_another_writer_added() -> Result<()> { + let temp = tempfile::tempdir()?; + let path = temp.path().join("m.cc"); + let mut node = CultMesh::create_node(&path, TestDocuments, Default::default())?; + let mut other = CultMesh::create_node(&path, TestDocuments, Default::default())?; + other.put("added", &Note { body: "other writer".to_string() })?; + let plain = |body: &str| rmp_serde::to_vec(&(body,)); + assert_eq!(node.apply_raw_snapshot_documents(vec![raw_note("snap", plain("s")?)])?, 1); + let reopened = CultMesh::create_node(&path, TestDocuments, Default::default())?; + assert_eq!(reopened.get_required::("added")?.body, "other writer"); + assert_eq!(reopened.get_required::("snap")?.body, "s"); + Ok(()) + } + #[test] fn node_round_trips_registered_documents() -> Result<()> { let temp = tempfile::tempdir()?; From a23ac7755acff318b134300b7003d2383d9f28c3 Mon Sep 17 00:00:00 2001 From: Libby Date: Thu, 12 Nov 2026 10:31:13 +0000 Subject: [PATCH 56/93] Rust mesh: pin that a snapshot spanning backing stores is refused whole (#137) --- packages/cultcache-rs/CHANGELOG.md | 2 +- packages/cultmesh-rs/src/lib.rs | 48 +++++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/packages/cultcache-rs/CHANGELOG.md b/packages/cultcache-rs/CHANGELOG.md index e1fad67d..7f3caa73 100644 --- a/packages/cultcache-rs/CHANGELOG.md +++ b/packages/cultcache-rs/CHANGELOG.md @@ -11,7 +11,7 @@ only in git history. Semver: this is a breaking change in the 0.x line (0.3.x to 0.4.0). - A record whose payload carries unread slots is read-only. A payload with a slot past its type's declared slots, at any depth (record, nested record, list element, union arm, unit arm), still decodes, and every write or removal of its key is now refused before any store is touched: `put`, `put_envelope`, `put_raw_envelope`, `put_prepared_batch` (the whole batch) and `delete`, so `update` too. The refusal is `SchemaConflictError` with the new `unread_slots`; its message names the type and both slot counts and never a slot value. Holders of such a record used to overwrite it and drop the extra slots silently. The payload nesting refusal still runs first. A held payload this build cannot read at all (an arm it lacks from a newer writer, or a corrupt record) is read-only too: every write or removal is refused with an error naming the type and key (a newer writer's record is read with that build; a corrupt one is restored from a backup or removed at the store itself, as no cache call removes it), and `is_writable` answers false. `load_soa` is a write of every held key it omits or changes, so it refuses to drop or replace a read-only record. `is_writable` also answers false for a key a store carries as a foreign record and for a type other than the one holding the key, as the writes do. Each write over a held key decodes the held payload once to ask (about 40% of a `put` of a 200,000-element record, measured in release); keys nothing holds pay nothing. -- cultmesh-rs `pull_rudp_catalog_snapshot` applies a snapshot as one cache commit (`put_prepared_batch`), as C# does: a snapshot document for a key the node holds read-only, or any other refusal, applies nothing. It used to apply document by document, so a refusal ended the pull with the earlier documents kept. A snapshot whose types route to different backing stores is now refused whole, as a batch cannot span stores. +- cultmesh-rs `pull_rudp_catalog_snapshot` applies a snapshot as one cache commit (`put_prepared_batch`), as C# does: a snapshot document for a key the node holds read-only, or any other refusal, applies nothing. It used to apply document by document, so a refusal ended the pull with the earlier documents kept. A snapshot whose types route to different backing stores is now refused whole, as a batch cannot span stores. C# `CultCache.Commit` refuses a cross-store commit the same way (`The batch spans A and B; a commit lands in one home store`), so the two agree; a test pins it. - `SchemaConflictError` gains the public field `unread_slots: Option`; code that builds it with a struct literal must set it (`None`). - A unit arm with slots (`[N, [extra...]]`) decodes as the unit arm and marks the record, where the derive refused it. - The registration map's value changes from the schema name to a private struct holding the schema name and the unread-slots check. diff --git a/packages/cultmesh-rs/src/lib.rs b/packages/cultmesh-rs/src/lib.rs index 310629e3..b1ee613b 100644 --- a/packages/cultmesh-rs/src/lib.rs +++ b/packages/cultmesh-rs/src/lib.rs @@ -739,7 +739,8 @@ impl CultMeshNode { /// of unread slots from a newer peer or a payload this build cannot read, or any other the cache judges) applies /// nothing. A schema id the registry does not know is skipped, except one with a `.vN` suffix, which is taken as /// registered and refuses the commit when its type is not (C# throws on an unknown schema too). The commit lands in - /// one backing store, so a snapshot spanning stores is refused whole. + /// one backing store, so a snapshot spanning stores is refused whole; C# `CultCache.Commit` refuses a cross-store batch too + /// ("The batch spans A and B; a commit lands in one home store"), so the two agree. fn apply_raw_snapshot_documents( &mut self, documents: Vec, @@ -1210,10 +1211,22 @@ mod tests { body: String, } + #[derive(Clone, Debug, PartialEq, Eq, cultcache_rs::DatabaseEntry)] + #[cultcache(type = "cultmesh.test.memo", schema = "CultMeshTestMemo")] + struct Memo { + #[cultcache(key = 0)] + body: String, + } + cultmesh_documents!(TestDocuments { Note => "cultmesh.test.note.v0", }); + cultmesh_documents!(TwoTypeDocuments { + Note => "cultmesh.test.note.v0", + Memo => "cultmesh.test.memo.v0", + }); + fn raw_note(key: &str, payload: Vec) -> cultnet_rs::CultNetRawDocumentRecord { cultnet_rs::CultNetRawDocumentRecord { schema_id: "cultmesh.test.note.v0".to_string(), @@ -1255,6 +1268,39 @@ mod tests { Ok(()) } + #[test] + fn a_snapshot_spanning_two_backing_stores_is_refused_whole_and_applies_nothing() -> Result<()> { + let temp = tempfile::tempdir()?; + let mut node = CultMesh::create_node(temp.path().join("m.cc"), TwoTypeDocuments, Default::default())?; + // A node has one store through the public API; route Memo to a second one by hand (C# refuses the same commit). + node.cache.add_backing_store( + SingleFileMessagePackBackingStore::new(temp.path().join("memo.cc")), + [Memo::TYPE], + )?; + node.cache.pull_all_backing_stores()?; + let plain = |body: &str| rmp_serde::to_vec(&(body,)); + let memo = |key: &str, payload| { + let mut record = raw_note(key, payload); + record.schema_id = "cultmesh.test.memo.v0".to_string(); + record + }; + let error = node + .apply_raw_snapshot_documents(vec![ + raw_note("n", plain("note")?), + memo("m", plain("memo")?), + ]) + .unwrap_err(); + assert!(error.to_string().contains("cannot span backing stores"), "{error}"); + assert!(node.get::("n")?.is_none()); + assert!(node.get::("m")?.is_none()); + assert!(!temp.path().join("m.cc").exists() && !temp.path().join("memo.cc").exists()); + + // Each store alone still takes its own snapshot. + assert_eq!(node.apply_raw_snapshot_documents(vec![memo("m", plain("memo")?)])?, 1); + assert_eq!(node.get_required::("m")?.body, "memo"); + Ok(()) + } + #[test] fn a_snapshot_applied_to_a_stale_node_keeps_the_records_another_writer_added() -> Result<()> { let temp = tempfile::tempdir()?; From f4fe424d4154e34d978b4d47e085b8538ceeefbb Mon Sep 17 00:00:00 2001 From: Libby Date: Thu, 12 Nov 2026 12:16:52 +0000 Subject: [PATCH 57/93] CultCache interop: C# union and nested-bytes store read by Rust read-only, and back (#139) --- .../examples/cultcache_interop.rs | 98 +++++++++++++++++++ packages/cultcache-ts/test/cult-cache.test.ts | 45 ++++++++- tests/GameCult.Caching.InteropPeer/Program.cs | 74 ++++++++++++++ 3 files changed, 215 insertions(+), 2 deletions(-) diff --git a/packages/cultcache-rs/examples/cultcache_interop.rs b/packages/cultcache-rs/examples/cultcache_interop.rs index 8efb9395..dcafee5f 100644 --- a/packages/cultcache-rs/examples/cultcache_interop.rs +++ b/packages/cultcache-rs/examples/cultcache_interop.rs @@ -1,6 +1,8 @@ use anyhow::Result; use anyhow::anyhow; use cultcache_rs::CultCache; +use cultcache_rs::CultSlots; +use cultcache_rs::CultUnion; use cultcache_rs::DatabaseEntry; use cultcache_rs::SingleFileMessagePackBackingStore; use serde_json::json; @@ -39,6 +41,100 @@ struct CultCacheInteropDeck { marks: Vec, } +// A union field and nested bytes: the shapes a Bifrost-style store holds beyond plain strings. +#[derive(Clone, Debug, PartialEq, Eq, CultUnion)] +enum InteropArm { + #[cultcache(key = 0)] + Text(InteropTextArm), + #[cultcache(key = 1)] + Pack(InteropPackArm), +} + +#[derive(Clone, Debug, PartialEq, Eq, CultSlots)] +struct InteropTextArm { + #[cultcache(key = 0)] + text: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, CultSlots)] +struct InteropPackArm { + #[cultcache(key = 0)] + label: String, + #[cultcache(key = 1)] + chunks: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "cultcache.interop-shape", schema = "CultCacheInteropShape")] +struct CultCacheInteropShape { + #[cultcache(key = 0)] + document_id: String, + #[cultcache(key = 1)] + arm: InteropArm, + #[cultcache(key = 2)] + raw: serde_bytes::ByteBuf, +} + +fn shapes() -> Vec { + vec![ + CultCacheInteropShape { + document_id: "shape:text".into(), + arm: InteropArm::Text(InteropTextArm { text: "union arm 0".into() }), + raw: (0..=255u8).collect::>().into(), + }, + CultCacheInteropShape { + document_id: "shape:pack".into(), + arm: InteropArm::Pack(InteropPackArm { + label: "union arm 1".into(), + chunks: vec![vec![0xc0, 0xc1, 0x00].into(), vec![].into(), vec![0x92, 0x01, 0x02].into()], + }), + raw: vec![0xde, 0xad, 0xbe, 0xef].into(), + }, + ] +} + +fn write_shapes(file: &str) -> Result<()> { + let mut cache = CultCache::new(); + cache.register_entry_type::()?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(file))?; + cache.pull_all_backing_stores()?; + for shape in shapes() { + cache.put(&shape.document_id, &shape)?; + } + print_shapes(shapes()) +} + +/// Opens the store through the read-only snapshot door: no lock file, no staging file, no rewrite. +fn read_shapes(file: &str) -> Result<()> { + let envelopes = SingleFileMessagePackBackingStore::new(file).pull_all_read_only_snapshot()?; + let mut found = Vec::new(); + for envelope in envelopes.iter().filter(|envelope| envelope.r#type == CultCacheInteropShape::TYPE) { + found.push(cultcache_rs::decode_payload::(&envelope.payload)?); + } + print_shapes(found) +} + +fn print_shapes(mut found: Vec) -> Result<()> { + let hex = |bytes: &[u8]| bytes.iter().map(|byte| format!("{byte:02X}")).collect::(); + found.sort_by(|a, b| a.document_id.cmp(&b.document_id)); + let rows: Vec<_> = found + .iter() + .map(|shape| { + let arm = match &shape.arm { + InteropArm::Text(text) => json!({ "kind": "text", "text": text.text }), + InteropArm::Pack(pack) => json!({ + "kind": "pack", + "label": pack.label, + "chunks": pack.chunks.iter().map(|chunk| hex(chunk)).collect::>(), + }), + }; + json!({ "documentId": shape.document_id, "arm": arm, "raw": hex(&shape.raw) }) + }) + .collect(); + println!("{}", serde_json::to_string(&rows)?); + Ok(()) +} + fn read_deck(file: &str) -> Result<()> { let mut cache = CultCache::new(); cache.register_entry_type::()?; @@ -70,6 +166,8 @@ fn main() -> Result<()> { "write" => write_note(file, require_arg(&options, "runtime-id")?)?, "read" => read_note(file)?, "read-deck" => read_deck(file)?, + "write-shapes" => write_shapes(file)?, + "read-shapes" => read_shapes(file)?, "hold-lock" => hold_lock(file, require_arg(&options, "ms")?.parse()?)?, _ => return Err(anyhow!("unknown mode {mode}")), } diff --git a/packages/cultcache-ts/test/cult-cache.test.ts b/packages/cultcache-ts/test/cult-cache.test.ts index 2636a9d3..4efa6d78 100644 --- a/packages/cultcache-ts/test/cult-cache.test.ts +++ b/packages/cultcache-ts/test/cult-cache.test.ts @@ -33,8 +33,7 @@ const cultcacheRsRoot = existsSync(resolve(cultLibRoot, "packages", "cultcache-r ? resolve(cultLibRoot, "packages", "cultcache-rs") : resolve(cultCacheTsRoot, "..", "cultcache-rs"); const rustInteropBinary = resolve( - cultcacheRsRoot, - "target", + process.env.CARGO_TARGET_DIR ?? resolve(cultcacheRsRoot, "target"), "debug", "examples", process.platform === "win32" ? "cultcache_interop.exe" : "cultcache_interop", @@ -1273,6 +1272,48 @@ test("CultCache v1 MessagePack stores are readable across TS, Rust, C#, and Pyth } }); +// A Bifrost-shaped store (a union field, nested bytes) written by one of C# and Rust is opened by the other: C# reads through +// its store, Rust through the read-only snapshot door, which takes no lock and writes nothing. Leftover staging files from an +// interrupted write of either runtime sit beside the store; a read neither deletes them nor lets them change what it reads, and +// the store file's bytes are the same after every read. +test("A store written by C# or Rust with a union and nested bytes is read back equal by the other, read-only, past leftover staging files", async () => { + await buildInteropPeers(); + const tempDir = await mkdtemp(join(tmpdir(), "cultcache-shapes-")); + const rustRead = (file: string) => runJsonCommand("rust-read-shapes", rustInteropBinary, ["read-shapes", "--file", file], cultcacheRsRoot); + const csharpRead = (file: string) => runJsonCommand("csharp-read-shapes", dotnetCommand, [csharpInteropDll, "read-shapes", "--file", file], cultLibRoot); + const peers = [ + { + writer: "csharp", + write: (file: string) => runJsonCommand("csharp-write-shapes", dotnetCommand, [csharpInteropDll, "write-shapes", "--file", file], cultLibRoot), + read: rustRead, + }, + { + writer: "rust", + write: (file: string) => runJsonCommand("rust-write-shapes", rustInteropBinary, ["write-shapes", "--file", file], cultcacheRsRoot), + read: csharpRead, + }, + ]; + for (const { writer, write, read } of peers) { + const file = join(tempDir, `${writer}.cc`); + const written = await write(file); + assert.equal(written.length, 2); + // The leftovers an interrupted write leaves: C# stages as `...tmp`, Rust as `..tmp`; both hold partial bytes. + const leftovers = [ + join(tempDir, `.${writer}.cc.0123456789abcdef0123456789abcdef.tmp`), + join(tempDir, `${writer}.cc.00000000-0000-4000-8000-000000000000.tmp`), + ]; + for (const leftover of leftovers) { + await writeFile(leftover, Buffer.from([0x93, 0xb2])); + } + const before = await readFile(file); + assert.deepEqual(await read(file), written, `${writer}'s store did not read back equal`); + assert.deepEqual(await readFile(file), before, `reading ${writer}'s store changed its bytes`); + for (const leftover of leftovers) { + assert.ok(await exists(leftover), `reading ${writer}'s store removed ${leftover}`); + } + } +}); + // Records and catalog entries a runtime does not own survive its writes. C# writes a store and each other runtime writes its own // note into it: C# reads its note back, and its record and catalog entry are as C# wrote them. C# then writes again, and the other // runtime's record and catalog entry are as that runtime wrote them. Identity is compared on the decoded MessagePack values, every diff --git a/tests/GameCult.Caching.InteropPeer/Program.cs b/tests/GameCult.Caching.InteropPeer/Program.cs index 3c8eb92b..f0f0479e 100644 --- a/tests/GameCult.Caching.InteropPeer/Program.cs +++ b/tests/GameCult.Caching.InteropPeer/Program.cs @@ -51,6 +51,12 @@ static async Task ProgramMainAsync(string[] args) case "edit-in-place": await EditInPlaceAsync(file, RequireArg(options, "kind")); return 0; + case "write-shapes": + await WriteShapesAsync(file); + return 0; + case "read-shapes": + await ReadShapesAsync(file); + return 0; case "hold-lock": HoldLock(file, int.Parse(RequireArg(options, "ms"), CultureInfo.InvariantCulture)); return 0; @@ -96,6 +102,49 @@ static async Task WriteAsync(string file, string runtimeId) WriteJsonLine(note); } +// Records a reader of a Bifrost-shaped store must open: a union field (two arms) and nested bytes (a bin member, a bin list). +static async Task WriteShapesAsync(string file) +{ + var cache = BuildCache(file); + await cache.PullAllBackingStoresAsync(); + foreach (var shape in InteropShapes()) + await cache.AddAsync(shape, new CultRecordHandle(new CultRecordKey(shape.DocumentId))); + cache.FlushAllBackingStores(); + WriteShapesJsonLine(InteropShapes()); +} + +static async Task ReadShapesAsync(string file) +{ + var cache = BuildCache(file); + await cache.PullAllBackingStoresAsync(); + WriteShapesJsonLine(cache.AllEntries.OfType()); +} + +static CultCacheInteropShape[] InteropShapes() => +[ + new() { DocumentId = "shape:text", Arm = new InteropTextArm { Text = "union arm 0" }, Raw = Enumerable.Range(0, 256).Select(i => (byte)i).ToArray() }, + new() + { + DocumentId = "shape:pack", + Arm = new InteropPackArm { Label = "union arm 1", Chunks = [new byte[] { 0xc0, 0xc1, 0x00 }, [], [0x92, 0x01, 0x02]] }, + Raw = [0xde, 0xad, 0xbe, 0xef] + } +]; + +// One line, ordered by document id, in the form the Rust peer prints. +static void WriteShapesJsonLine(IEnumerable shapes) => + Console.Out.WriteLine(JsonSerializer.Serialize(shapes.OrderBy(shape => shape.DocumentId, StringComparer.Ordinal).Select(shape => new + { + documentId = shape.DocumentId, + arm = shape.Arm switch + { + InteropTextArm text => (object)new { kind = "text", text = text.Text }, + InteropPackArm pack => new { kind = "pack", label = pack.Label, chunks = pack.Chunks.Select(Convert.ToHexString).ToArray() }, + _ => throw new InvalidOperationException("Unknown arm.") + }, + raw = Convert.ToHexString(shape.Raw) + }))); + static async Task ReadAsync(string file) { var cache = BuildCache(file); @@ -328,3 +377,28 @@ public sealed class CultCacheInteropMark [Key(0)] public string Label { get; set; } = string.Empty; [Key(1)] [CultElementId] public string Id { get; set; } = string.Empty; } + +[CultDocument("cultcache.interop-shape", "cultcache.interop_shape.v1")] +[MessagePackObject] +public sealed class CultCacheInteropShape +{ + [Key(0)] [CultName] public string DocumentId { get; set; } = string.Empty; + [Key(1)] public InteropArm Arm { get; set; } = new InteropTextArm(); + [Key(2)] public byte[] Raw { get; set; } = Array.Empty(); +} + +[Union(0, typeof(InteropTextArm)), Union(1, typeof(InteropPackArm))] +public abstract class InteropArm { } + +[MessagePackObject] +public sealed class InteropTextArm : InteropArm +{ + [Key(0)] public string Text { get; set; } = string.Empty; +} + +[MessagePackObject] +public sealed class InteropPackArm : InteropArm +{ + [Key(0)] public string Label { get; set; } = string.Empty; + [Key(1)] public List Chunks { get; set; } = new(); +} From 5f5364e4dcea48df4e82b7a0061e97940de88ac6 Mon Sep 17 00:00:00 2001 From: Libby Date: Thu, 12 Nov 2026 12:16:58 +0000 Subject: [PATCH 58/93] Lower System.Text.Json floor to 8.0.5 (#140) --- .../GameCult.Caching.MessagePack.csproj | 2 +- src/GameCult.Geometry/GameCult.Geometry.csproj | 2 +- src/GameCult.Networking/GameCult.Networking.csproj | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/GameCult.Caching.MessagePack/GameCult.Caching.MessagePack.csproj b/src/GameCult.Caching.MessagePack/GameCult.Caching.MessagePack.csproj index 63cbf614..0afdd0d3 100644 --- a/src/GameCult.Caching.MessagePack/GameCult.Caching.MessagePack.csproj +++ b/src/GameCult.Caching.MessagePack/GameCult.Caching.MessagePack.csproj @@ -8,7 +8,7 @@ - + diff --git a/src/GameCult.Geometry/GameCult.Geometry.csproj b/src/GameCult.Geometry/GameCult.Geometry.csproj index 9736e1b7..744bd109 100644 --- a/src/GameCult.Geometry/GameCult.Geometry.csproj +++ b/src/GameCult.Geometry/GameCult.Geometry.csproj @@ -12,7 +12,7 @@ - + diff --git a/src/GameCult.Networking/GameCult.Networking.csproj b/src/GameCult.Networking/GameCult.Networking.csproj index 08ef2ed6..68e13794 100644 --- a/src/GameCult.Networking/GameCult.Networking.csproj +++ b/src/GameCult.Networking/GameCult.Networking.csproj @@ -9,7 +9,7 @@ - + From 498b8483333d47ca56520b93e12cd97a374bc83d Mon Sep 17 00:00:00 2001 From: Libby Date: Thu, 12 Nov 2026 18:47:43 +0000 Subject: [PATCH 59/93] Docs: single-file lock contract, leftover temp file and pair-backup procedures (#138) --- packages/cultcache-rs/README.md | 4 +- .../Contracts/cultcache-store-composition.md | 137 +++++++++++++++++- src/GameCult.Caching/README.md | 15 +- 3 files changed, 147 insertions(+), 9 deletions(-) diff --git a/packages/cultcache-rs/README.md b/packages/cultcache-rs/README.md index da4185e2..2d217083 100644 --- a/packages/cultcache-rs/README.md +++ b/packages/cultcache-rs/README.md @@ -39,8 +39,8 @@ CultCache is a domain cache with persistence adapters. file access with a sidecar lock file. This is not an ORM, not a database, and not distributed consensus in a novelty -hat. If multiple processes write the same backing file, use an external lock or -a coordinator. +hat. Several processes may write the same single-file store, one at a time under +its sidecar lock; write ordering across keys or stores needs a coordinator. ## Current API diff --git a/src/GameCult.Caching/Contracts/cultcache-store-composition.md b/src/GameCult.Caching/Contracts/cultcache-store-composition.md index 01f7a5c9..3e7bd425 100644 --- a/src/GameCult.Caching/Contracts/cultcache-store-composition.md +++ b/src/GameCult.Caching/Contracts/cultcache-store-composition.md @@ -315,11 +315,138 @@ refuses to replace a file whose header it cannot read, and leaves it as it was. in that store, and leaves the store clean. - A conditional commit lands the batch onto the file as it is under the lock. -C#, Rust and TypeScript writers exclude each other on the shared lock file; -Python takes no cross-process lock yet (`variants:follow_up:py-shared-write-lock`), -and Rust's open fails rather than waits while a C# writer holds the lock -(`variants:follow_up:rust-lock-open-waits`). Another runtime need not obey the -`storedAt` rule. +C#, Rust, TypeScript and Python single-file writers exclude each other on the +shared lock file. Another runtime need not obey the `storedAt` rule. + +## Operating a single-file store + +What the lock guarantees, per runtime. Every claim cites the code. + +- **Scope.** The lock is the sidecar `.lock`, taken by every write of a + single-file store, never by a read except in Rust. It is advisory: it excludes + only openers that also take it, in this process and others. C# opens it + share-none (`CultCache.cs:5166-5183`; .NET emulates that with `flock(LOCK_EX)` + on Unix, switched off by `DOTNET_SYSTEM_IO_DISABLEFILELOCKING`, see + `docs/research/cross-process-file-locking-prior-art.md`); Rust takes + `fs2` exclusive (`cultcache-rs/src/lib.rs:1721-1726`); TypeScript `flock` or + `LockFileEx` (`cultcache-ts/src/store-file.ts:340-382`); Python the same + (`cultcache-py/src/cultcache_py/stores.py:342-389`). A tool that opens and + writes the `.cc` file without the lock is not excluded. `flock` is not + reliable on every network filesystem; keep stores on a local one. +- **What a second writer sees.** It waits. C# polls every 10 ms for 30 seconds, + then the `IOException` propagates (`CultCache.cs:5166-5183`); with `wait: false` + (`TryCommit`) it returns `Contended` (`CultCache.cs:2052`, `2897`, `4691-4693`). + TypeScript times out after 30 s (`store-file.ts:173-195`), Python raises + `TimeoutError` after `lock_timeout`, 30 s by default (`stores.py:342-354`). + Rust waits without a timeout on its write paths (`lib.rs:1721-1726`); only its + `try_with_exclusive_lock` returns without waiting (`lib.rs:1832-1843`). Each + write re-reads the file after taking the lock and applies its staged keys onto + it (`CultCache.cs:4659-4676`), so waiting writers merge rather than overwrite. + The in-memory cache of a writer is not refreshed by another's write until its + next `PullAll`, which does nothing while that cache is dirty + (`CultCache.cs:4582-4588`). +- **What a reader sees.** C#, TypeScript and Python read without the lock + (`CultCache.cs:5139`, `5227`; `single-file-messagepack-backing-store.ts:200`; + `stores.py:144-152`). The store file is only ever replaced by rename of a fully + written, fsynced temp file (`CultCache.cs:5191-5213`, `lib.rs:1693-1712`, + `store-file.ts:70-95`, `stores.py:316-326`), so a read gets one complete + generation, old or new, never a mix. Rust `pull_all` takes the lock shared and + so waits behind a writer (`lib.rs:1926-1930`); it creates the `.lock` file if + absent, so on a read-only directory use `pull_all_read_only_snapshot` + (`lib.rs:1667-1669`), which takes no lock and relies on the producer's atomic + replace. +- **Not covered.** A unique-index check or condition spanning two stores, or any + invariant between two files: each store has its own lock and no store carries a + generation counter (the file is `[formatVersion, schemaCatalog, records]`); + a record's identity is its `version`, the SHA-256 of its stored bytes. No + library writer holds two store locks at once (`CultCache.cs:2917-2926` judges + one store, releases, then commits the home). Python's `JsonLinesBackingStore` + takes only an in-process lock (`stores.py:73-80`) and is not a single-file store + in this sense. +- **The `.lock` file is not a stale-lock marker.** It is never written or deleted + by a library writer (`store-file.ts:167-172`, `stores.py:342-345`), and the + lock is released when the holding process exits. A `.lock` file left on disk is + normal. Do not delete it while any process may open the store: a new opener + would create a second file and take a second, independent lock on it. + +### A leftover temp file + +A write stages its bytes in a temp file beside the store and renames it over the +store. The name is consumed by the rename, so a temp file that still exists is a +write that was never applied. The store file itself is never partial because of +it. + +| Runtime | Temp name | Source | +|---|---|---| +| C# | `..<32 hex>.tmp` | `CultCache.cs:5191` | +| Rust | `..tmp` | `lib.rs:4268-4275` | +| TypeScript | `.tmp---` | `store-file.ts:70` | +| Python | `.tmp--` | `stores.py:316` | + +- **Which temp files are safe to delete.** One that no writer is writing: + delete only while you hold `.lock` yourself + (`flock .lock rm `, Linux, util-linux) or with every writer of + the store stopped. Merely testing that the lock is free is not enough: a writer + can take it and stage a new temp file the moment after the test. While you hold + the lock, every temp file of that store is abandoned. Deleting one a writer is + still writing destroys that write. +- **How to know the write was incomplete.** The temp file existing is the proof. + Its content may be partial (killed during the write) or whole (killed between + the fsync and the rename); either way it was not applied, and the writer that + made it never saw its flush succeed. The store holds the last completed write. + Do not rename a temp file over the store by hand. Rust additionally reports a + rename that landed but whose directory fsync failed as `ReplacedNotDurable` + (`lib.rs:1685-1688`; TypeScript `StoreReplacedNotDurableError`, + `store-file.ts:88-92`): after a power loss the store may still be the earlier + generation. +- **What the store does about it.** Opening and reading ignore temp files and + never delete them. A writer removes only its own temp file when its write + throws (`CultCache.cs:5216-5222`, `store-file.ts:82-84`, `stores.py:327-331`, + `lib.rs:1707-1710`); a killed process skips that. Rust alone sweeps: its next + write, under the exclusive lock, deletes every regular file named + `..tmp` (`lib.rs:1698`, `4318-4352`). C#, TypeScript and + Python never sweep, and their unique names mean leftovers accumulate until an + operator deletes them. A store file that cannot be read (truncated, wrong + header) is refused at open and left byte-identical (the open reads through + `ReadSnapshot`, `CultCache.cs:4590`, `5104-5118`, whose `ReadStore` must throw + `CultStoreUnreadableException` for anything that is not exactly one store, + `4510-4516`); that is the store, not a temp file, and is + restored from a copy, never deleted and rewritten. + +### Backing up a pair of stores + +Each store is whole on its own: copying one `.cc` file is safe at any moment, +because the file is only ever replaced whole, and a copy of the path is one +complete generation. Two stores have two independent locks and no shared +generation, so copying them one after the other can capture store A at one +moment and store B at a later one. If an application invariant spans the pair, +that copy may violate it. Back up the pair at one point in time: + +1. **Preferred, quiesce.** Stop (or have flush and close) every process that + writes either store. Flush staged writes first; `PullAll` and the dispose + flush do not capture unflushed state of a still-running cache. Copy both `.cc` + files. Do not copy `.lock` files or temp files. Start the writers again. +2. **Without stopping (Unix): per-store consistency only.** Take both locks in + one fixed order (sorted by full path), copy while holding both, release: + `flock A.cc.lock flock B.cc.lock cp A.cc B.cc /backup/dir/`. + This does not make the pair consistent. A writer holds one store lock at a + time, so it can land A and then B with a gap between; holding both locks + cannot pause a writer that has landed A and not yet B, so the copy can hold A + after and B before that pair of writes. It guarantees each copy is one + complete generation of its store and nothing more. Any invariant spanning the + pair requires step 1. Cooperating writers wait (30 s in C#, TypeScript and + Python; Rust waits indefinitely), so keep the hold short. Unflushed state of + live caches is not in it. On Windows, no shipped tool holds the C# share-none + lock for you: quiesce. +3. **Verify.** Open each copy read-only with the runtime that will use it (for + Rust, `pull_all_read_only_snapshot`); an unreadable copy is refused, not + repaired (`CultCache.cs:4510-4516`, `5104-5118`). +4. **Restore both files together** from the same backup, with every writer + stopped, then restart so each cache loads fresh. Copy each file to a temp + name in the store's own directory and `mv` it over the store: a plain `cp` + over the store is not atomic, and C#, TypeScript and Python readers take no + lock, so they can read a torn file. Restoring one half of a pair from a + different backup recreates the skew. Leave the `.lock` files as they are. ## Serialization options diff --git a/src/GameCult.Caching/README.md b/src/GameCult.Caching/README.md index 6defed61..6c76f5cd 100644 --- a/src/GameCult.Caching/README.md +++ b/src/GameCult.Caching/README.md @@ -307,8 +307,19 @@ Useful surfaces: This is built for callers like Aquarium that want durable runtime state while still choosing *when* to pay the write cost. -Single-file MessagePack remains a single-writer format. The atomic replace path -protects against partial writes, not competing writers trampling each other. +A single-file store admits any number of writers, in one process or many, one at +a time. Every write (flush, `PushAll`, `CommitBatch`, cacheless `WriteRecord`) +takes an exclusive advisory lock on the sidecar `.lock`, reads the file as +it is then, applies its own staged keys onto it and replaces the file by a +write-to-temp-then-rename. Two writers never interleave bytes, and a record +another writer put there survives; a key both staged holds the last write +(`Contracts/cultcache-store-composition.md`, "Locking"). What the lock does not +give is cross-key or cross-store atomicity, a lock that binds a program that +ignores it, or protection on a filesystem without working `flock`. A second +writer waits (30 seconds, then `IOException`; `TryCommit` reports `Contended` +instead of waiting); readers take no lock and always see one complete file. The +operator procedures for a leftover temp file and for backing up two stores are +in the same contract document, "Operating a single-file store". ## Schema migration diagnostics From dca0c5d095e193b737c5419d7c8f65c72ea96644 Mon Sep 17 00:00:00 2001 From: Libby Date: Thu, 12 Nov 2026 18:47:44 +0000 Subject: [PATCH 60/93] C#: pin the one-value refusal on codec and Mesh single-file reads (#141) --- .../BytesAfterVectorTests.cs | 9 +++++++++ .../GameCult.Mesh.Tests/BytesAfterPathTests.cs | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/tests/GameCult.Caching.Tests/BytesAfterVectorTests.cs b/tests/GameCult.Caching.Tests/BytesAfterVectorTests.cs index 86d677e6..65b1d6da 100644 --- a/tests/GameCult.Caching.Tests/BytesAfterVectorTests.cs +++ b/tests/GameCult.Caching.Tests/BytesAfterVectorTests.cs @@ -110,6 +110,15 @@ namespace GameCult.Caching.Tests Assert.That(message, Does.Contain(typeof(UnionSurface).FullName).And.Contain($"1 {Clause}").And.Contain("refused on read")); } + // The inspector reads documents through CultCodec.Deserialize (type == document), not DecodeDocument: its branch is its own path. + [Test] + public void CodecDeserializeOfADocumentRefusesATail() + { + var codec = CultCacheMessagePack.CreateCodec(Registry); + var message = Message(() => codec.Deserialize(typeof(UnionSurface), typeof(UnionSurface), Base.Concat(new byte[] { 0xc0 }).ToArray())); + Assert.That(message, Does.Contain(typeof(UnionSurface).FullName).And.Contain($"1 {Clause}").And.Contain("refused on read")); + } + [Test] public void StoreLoadRefuses() { diff --git a/tests/GameCult.Mesh.Tests/BytesAfterPathTests.cs b/tests/GameCult.Mesh.Tests/BytesAfterPathTests.cs index ed9b904b..28c6d3de 100644 --- a/tests/GameCult.Mesh.Tests/BytesAfterPathTests.cs +++ b/tests/GameCult.Mesh.Tests/BytesAfterPathTests.cs @@ -58,4 +58,22 @@ public class BytesAfterPathTests .MakeGenericMethod(typeof(CultMeshCdnArtifactManifest)); Refusal(() => read.Invoke(null, new object[] { snapshot, record.SchemaId, record.RecordKey })).Should().Contain(Clause); } + + [Test] + public void ASingleFileDocumentReadRefusesATail() + { + var path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"cultlib-bytesafter-mesh-{Guid.NewGuid():N}.cc"); + try + { + var descriptor = CultDocumentRegistry.Shared.GetRequired(); + var schema = new CultMeshSingleFileDocumentSchema(descriptor.SchemaId, descriptor.SchemaName, descriptor.SchemaVersion); + var payload = WithTail(Manifest()).Payload; + CultMesh.WriteSingleFileDocumentPayload(path, new CultRecordKey("k"), schema, null, payload); + Refusal(() => CultMesh.ReadSingleFileDocument(path, new CultRecordKey("k"))).Should().Contain(Clause); + } + finally + { + System.IO.File.Delete(path); + } + } } From c3de792a7eab5de258cb3258059d117193748b5c Mon Sep 17 00:00:00 2001 From: Libby Date: Thu, 12 Nov 2026 18:56:20 +0000 Subject: [PATCH 61/93] Bytes-after residual lows: depth pin, interop tags, py shard-log tail, Rust load pins (#142) --- .github/workflows/cultnet-interop.yml | 6 +++ packages/cultcache-rs/tests/union_parity.rs | 17 +++++++ packages/cultcache-rs/tests/unique_index.rs | 50 ++++++++++++++++++- packages/cultnet-py/CHANGELOG.md | 2 +- .../cultnet-py/src/cultnet_py/replication.py | 9 ++-- packages/cultnet-py/tests/test_cultnet.py | 39 ++++++++++----- .../BytesAfterPathTests.cs | 33 +++++++++++- 7 files changed, 137 insertions(+), 19 deletions(-) diff --git a/.github/workflows/cultnet-interop.yml b/.github/workflows/cultnet-interop.yml index 8f85506e..e053e123 100644 --- a/.github/workflows/cultnet-interop.yml +++ b/.github/workflows/cultnet-interop.yml @@ -29,6 +29,12 @@ jobs: steps: - name: Check out CultLib uses: actions/checkout@v4 + with: + # packages/cultnet-py/tests/test_declared_dependency_ranges.py reads the cultcache-py + # release tags and their file contents with `git show`; a shallow checkout has no tags + # and the test skips itself. + fetch-depth: 0 + fetch-tags: true - name: Set up .NET uses: actions/setup-dotnet@v4 diff --git a/packages/cultcache-rs/tests/union_parity.rs b/packages/cultcache-rs/tests/union_parity.rs index bc3aba6d..5dbe0e45 100644 --- a/packages/cultcache-rs/tests/union_parity.rs +++ b/packages/cultcache-rs/tests/union_parity.rs @@ -619,6 +619,23 @@ fn depth_is_refused_at_load() { assert!(cache.snapshot().is_empty()); } +// load_soa is a load too: a table holding a payload nested past the bound is refused with the typed depth refusal, the +// prior view is kept, and a tail behind the depth is still the depth refusal. +#[test] +fn load_soa_refuses_a_payload_nested_past_the_bound() { + let mut deep = vec![0x91u8; MAX_PAYLOAD_DEPTH + 1]; + deep.push(0xc0); + let mut cache = CultCache::new(); + cache.register_entry_type::().unwrap(); + cache.load_soa(CultCacheSoaTable::from_envelopes(vec![envelope("kept", &vector("union-surface.msgpack"))])).unwrap(); + let before = cache.snapshot(); + for payload in [deep.clone(), [deep.clone(), vec![0xc0]].concat()] { + let error = cache.load_soa(CultCacheSoaTable::from_envelopes(vec![envelope("k", &payload)])).unwrap_err(); + assert_eq!(error.downcast_ref::(), Some(&TOO_DEEP), "{error:#}"); + assert_eq!(cache.snapshot(), before, "a refused load keeps the prior view"); + } +} + // A cache whose load was refused refuses every write and removal and leaves the store as it was: a // later write would otherwise persist the empty image over the records it never read. #[test] diff --git a/packages/cultcache-rs/tests/unique_index.rs b/packages/cultcache-rs/tests/unique_index.rs index 193ba6fb..58b97293 100644 --- a/packages/cultcache-rs/tests/unique_index.rs +++ b/packages/cultcache-rs/tests/unique_index.rs @@ -6,7 +6,7 @@ use std::path::{Path, PathBuf}; use cultcache_rs::{ - CultCache, CultCacheEnvelope, CultCacheSoaTable, DatabaseEntry, SingleFileMessagePackBackingStore, + CultCache, CultCacheEnvelope, CultCacheLoadRefused, CultCacheSoaTable, DatabaseEntry, SingleFileMessagePackBackingStore, }; use serde::Deserialize; @@ -419,6 +419,54 @@ fn a_load_of_a_duplicate_image_is_refused_and_keeps_the_prior_view() { soa.put("item:y", &unique("K")).unwrap(); } +// A pull refused for a duplicate unique-index value, or for one key held under two types in two stores, closes the same +// write gate a payload refusal does: the cache holds no image of its stores, so a write would lay the empty image over them. +#[test] +fn a_pull_refused_for_a_duplicate_index_or_a_retype_closes_every_write() { + fn assert_gated(cache: &mut CultCache, path: &Path, door_label: &str) { + let before = std::fs::read(path).unwrap(); + let errors = [ + ("put", cache.put("item:new", &unique("N")).unwrap_err()), + ("put_raw_envelope", cache.put_raw_envelope(cache.prepare_entry("item:new", &unique("N")).unwrap().0).unwrap_err()), + ("delete", cache.delete::("item:keep").unwrap_err()), + ]; + for (door, error) in errors { + assert!(error.downcast_ref::().is_some(), "{door_label} {door}: {error:#}"); + } + assert_eq!(std::fs::read(path).unwrap(), before, "{door_label}: the store changed"); + } + + // Duplicate index value found in the stored image. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("dup.cc"); + let mut cache = cache_at(&path); + cache.put("item:keep", &unique("K")).unwrap(); + let mut twin = twin_cache_at(&path); + twin.put("item:b", &Twin { sku: "S".into(), category: "c".into() }).unwrap(); + twin.put("item:a", &Twin { sku: "S".into(), category: "c".into() }).unwrap(); + assert!(error_text(cache.pull_all_backing_stores()).contains("both hold unique index 'Sku'")); + assert_gated(&mut cache, &path, "duplicate index"); + + // One key held under two types by two stores. The cache pulled cleanly first, so only the refused pull can close the gate. + let (first, second) = (dir.path().join("first.cc"), dir.path().join("second.cc")); + let mut cache = CultCache::new(); + cache.register_entry_type::().unwrap(); + cache.register_entry_type::().unwrap(); + cache.register_entry_type::().unwrap(); + cache.add_backing_store(SingleFileMessagePackBackingStore::new(&first), [Plain::TYPE]).unwrap(); + cache.add_backing_store(SingleFileMessagePackBackingStore::new(&second), [Other::TYPE]).unwrap(); + cache.pull_all_backing_stores().unwrap(); + cache.put("k", &Plain { value: "p".into() }).unwrap(); + let mut seed = CultCache::new(); + seed.register_entry_type::().unwrap(); + seed.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&second)).unwrap(); + seed.pull_all_backing_stores().unwrap(); + seed.put("k", &Other { sku: "o".into() }).unwrap(); + assert!(error_text(cache.pull_all_backing_stores()).contains("\"k\"")); + assert_gated(&mut cache, &first, "retype"); + assert_eq!(cache.get::("k").unwrap().unwrap().value, "p", "a refused pull keeps the prior view"); +} + #[test] fn a_multi_index_store_with_a_duplicate_on_the_second_alias_refuses_to_open() { let dir = tempfile::tempdir().unwrap(); diff --git a/packages/cultnet-py/CHANGELOG.md b/packages/cultnet-py/CHANGELOG.md index 525bd4bd..b7e73ae3 100644 --- a/packages/cultnet-py/CHANGELOG.md +++ b/packages/cultnet-py/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased (breaking; version 0.x, so the next minor) -- `decode_witness_artifact_bundle_payload` (and `CultNetWitnessArtifactBundle.from_payload`) read the payload through `cultcache_py.read_payload_tree`: bytes after its first MessagePack value raise `ValueError` `cultnet.witness_artifact_bundle payload has N bytes after its first MessagePack value (refused on read).` instead of `msgpack.ExtraData`, and a payload nested past 256 raises `CultCachePayloadDepthError`. Needs `cultcache-py` 0.4.0, the release that exports `read_payload_tree`: the dependency is now `cultcache-py>=0.4.0` (it was `>=0.2.0`, which admits releases whose `import cultnet_py` fails), and `tests/test_declared_dependency_ranges.py` fails when a declared bound admits a tagged release lacking a name the package imports. The same check found `cultmesh-py` importing `cache.index_text`, which `cultcache-py` 0.2.0 lacks; its bound is `cultcache-py>=0.4.0` too (it has no changelog of its own). A raw record with bytes after its first value gives no schema hint to `resolve_document_for_raw_record`, so an unknown schema id stays `KeyError` rather than being routed; a registered one is refused by count when applied. +- `decode_witness_artifact_bundle_payload` (and `CultNetWitnessArtifactBundle.from_payload`) read the payload through `cultcache_py.read_payload_tree`: bytes after its first MessagePack value raise `ValueError` `cultnet.witness_artifact_bundle payload has N bytes after its first MessagePack value (refused on read).` instead of `msgpack.ExtraData`, and a payload nested past 256 raises `CultCachePayloadDepthError`. Needs `cultcache-py` 0.4.0, the release that exports `read_payload_tree`: the dependency is now `cultcache-py>=0.4.0` (it was `>=0.2.0`, which admits releases whose `import cultnet_py` fails), and `tests/test_declared_dependency_ranges.py` fails when a declared bound admits a tagged release lacking a name the package imports. The same check found `cultmesh-py` importing `cache.index_text`, which `cultcache-py` 0.2.0 lacks; its bound is `cultcache-py>=0.4.0` too (it has no changelog of its own). `resolve_document_for_raw_record` reads the schema hint from a raw record's first value whether or not bytes follow it, so a record routed by its stamp is refused by count when applied (`ValueError`, in a snapshot, a shard log and a direct apply) like one routed by a registered schema id; `apply_shard_log_response` used to skip it silently as an unknown schema. A tail on a payload whose first value names no registered schema is still `KeyError`, which a shard log skips. - `apply_raw_document_record` and `apply_raw_snapshot` raise `SchemaConflictError` when a remote record is of another type than the one the cache holds at its key (`cultcache-py` holds one record per key whatever its type); the cache is unchanged, and the caller decides whether to drop the record or the peer. `cultmesh-py`'s server answers such a put with `message_invalid`; its client's `read_next_change` lets the error out. - `CultNetDatabaseChange` carries `commit_id` and `commit_count` (`commitId`/`commitCount`; None outside a multi-record commit, as C# writes nil): `from_wire` refuses an empty or non-string id and a count that is not an integer of at least 2, `to_wire` writes them when set, and the published `database_change_raw` schema lists both. Python applies no batch. The `simulation_fact` document's `shard`, `frame`, `subject` and `claim_kind` aliases are `roles` (many facts share each); the catalog bytes do not move. - Decodes the write receipt frames of `contracts/cultnet/interop/write-receipt.md`: `parse_message` validates `cultnet.write_admitted.v0` and `cultnet.error.v1` against their closed schemas (unknown keys, empty or non-string ids, an unknown operation or code are refused; `error.v1` requires `messageId`, string or null; `limit_reached` is `error.v1` only). Added `CULTNET_ERROR_V1_CODES`, `CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS`, `write_content_hash` and `hello_advertises_write_receipts`; `hello` takes `supported_message_versions`. `tests/test_write_receipt.py` replays the vectors' `contentHash`, `wire` and `schemaInvalid` sections. Not implemented here: the client write tracker (`scenarios`) and the server rules (`dedupe`, `blankFields`); this package has no receipt server and no write client. diff --git a/packages/cultnet-py/src/cultnet_py/replication.py b/packages/cultnet-py/src/cultnet_py/replication.py index 28f5da33..345f1c62 100644 --- a/packages/cultnet-py/src/cultnet_py/replication.py +++ b/packages/cultnet-py/src/cultnet_py/replication.py @@ -174,10 +174,11 @@ def _infer_schema_version_from_payload(payload: bytes) -> str | None: import msgpack # type: ignore try: decoded = msgpack.unpackb(payload, raw=False) - except msgpack.ExtraData: - # A payload with bytes after its value gives no routing hint: the record is unroutable by inference, and a - # registered schema id routes it to a definition whose decode refuses the tail by count. - return None + except msgpack.ExtraData as extra: + # The hint is read from the first value; the tail is not this function's to judge. The routed definition refuses + # it by count when the record is applied, as it does for a record routed by a registered schema id. Treating a + # tail as "no hint" made a stamp-routed record look like an unknown schema, which a shard log skips silently. + decoded = extra.unpacked except Exception: return None if isinstance(decoded, list) and decoded and isinstance(decoded[0], str): diff --git a/packages/cultnet-py/tests/test_cultnet.py b/packages/cultnet-py/tests/test_cultnet.py index cdfaee5b..73b41a79 100644 --- a/packages/cultnet-py/tests/test_cultnet.py +++ b/packages/cultnet-py/tests/test_cultnet.py @@ -1971,7 +1971,7 @@ class CultNetTests(unittest.TestCase): apply_raw_document_record(cache, schema_document_map([first, second]), raw(first)) # the same type still replaces self.assertEqual(cache.get_required(first, "shared").name, "remote") - def test_a_raw_record_with_a_tail_is_unroutable_by_inference_and_refused_by_count_when_routed(self) -> None: + def test_a_raw_record_with_a_tail_is_routed_by_its_stamp_and_refused_by_count_on_every_apply_path(self) -> None: import msgpack # type: ignore from cultcache_py import define_document_type from cultnet_py import resolve_document_for_raw_record @@ -1986,18 +1986,33 @@ class CultNetTests(unittest.TestCase): "payloadEncoding": "messagepack", "payload": payload} documents = schema_document_map([document]) - # Routing by inference reads a hint, not a payload: a payload with a tail gives no hint, so an unknown schema id - # stays the unknown-schema error (KeyError) even when the first value names a registered schema. - self.assertIs(resolve_document_for_raw_record(documents, "unknown", raw("unknown", head)), document) + # The stamp is read from the first value whether or not a tail follows, so an unknown schema id whose payload names + # a registered schema routes to it; the tail is then refused by count where the record is applied. + self.assertIs(resolve_document_for_raw_record(documents, "unknown", raw("unknown", head + b"\xc0")), document) + for schema_id in (known, "unknown"): + cache = CultCache() + cache.register_document_type(document) + with self.assertRaises(ValueError, msg=schema_id) as caught: + apply_raw_document_record(cache, documents, raw(schema_id, head + b"\xc0")) + self.assertIn("1 bytes after its first MessagePack value", str(caught.exception)) + self.assertIsNone(cache.get(document, "k")) + snapshot = {"schemaVersion": "cultnet.snapshot_response_raw.v0", "messageId": "s", + "documents": [raw(schema_id, head + b"\xc0")]} + with self.assertRaises(ValueError, msg=schema_id): + apply_raw_snapshot(cache, [document], snapshot) + log = {"schemaVersion": "cultnet.shard_log_response.v0", "messageId": "l", "shardId": "s", "shardEpoch": 1, + "resyncRequired": False, + "entries": [{"sequence": 0, "changeKind": "updated", + "put": {"schemaVersion": "cultnet.document_put_raw.v0", "messageId": "p", + "document": raw(schema_id, head + b"\xc0"), "shardId": "s", "shardEpoch": 1}}]} + with self.assertRaises(ValueError, msg=schema_id) as caught: + apply_shard_log_response(cache, [document], log) + self.assertIn("1 bytes after its first MessagePack value", str(caught.exception)) + self.assertIsNone(cache.get(document, "k")) + # A tail on a payload that names no registered schema is still an unknown schema, which a shard log skips. + stranger = msgpack.packb(["tests.stranger.v1", "x"], use_bin_type=True) + b"\xc0" with self.assertRaises(KeyError): - resolve_document_for_raw_record(documents, "unknown", raw("unknown", head + b"\xc0")) - # A registered schema id routes, and the tail is refused by count when the record is applied. - cache = CultCache() - cache.register_document_type(document) - with self.assertRaises(ValueError) as caught: - apply_raw_document_record(cache, documents, raw(known, head + b"\xc0")) - self.assertIn("1 bytes after its first MessagePack value", str(caught.exception)) - self.assertIsNone(cache.get(document, "k")) + resolve_document_for_raw_record(documents, "unknown", raw("unknown", stranger)) def test_cultnet_replication_helpers_apply_raw_snapshot_and_shard_log(self) -> None: document = define_database_entry_type( diff --git a/tests/GameCult.Networking.Tests/BytesAfterPathTests.cs b/tests/GameCult.Networking.Tests/BytesAfterPathTests.cs index 6a56ceb1..0b125075 100644 --- a/tests/GameCult.Networking.Tests/BytesAfterPathTests.cs +++ b/tests/GameCult.Networking.Tests/BytesAfterPathTests.cs @@ -21,7 +21,15 @@ namespace GameCult.Networking.Tests [Key(1)] public int N = 3; } - private static readonly CultDocumentRegistry Documents = CultDocumentRegistry.ForTypes(new[] { typeof(TailDoc) }); + [CultDocument("tests.bytes_after_deep", "tests.bytes_after_deep.v1")] + [MessagePackObject] + public sealed class DeepDoc + { + [Key(0)] [CultName] public string Id = "a"; + [Key(1)] public DeepDoc Inner; + } + + private static readonly CultDocumentRegistry Documents = CultDocumentRegistry.ForTypes(new[] { typeof(TailDoc), typeof(DeepDoc) }); private static byte[] WithTail() => CultDocumentMessagePackSerialization.SerializeUntyped(new TailDoc(), typeof(TailDoc), Documents).Concat(new byte[] { 0xc0 }).ToArray(); @@ -58,5 +66,28 @@ namespace GameCult.Networking.Tests var registry = new CultNetDocumentRegistry(Documents); Refuses(() => registry.DeserializeRawDocument(Raw(registry, WithTail()))); } + + // The write side of the same rule: a record with no binding is serialized by SerializeUntyped, and a payload nested past + // the limit is refused there, before it can reach the wire. + [Test] + public void ToRawRecordWithNoBindingRefusesAPayloadNestedPastTheLimit() + { + var registry = new CultNetDocumentRegistry(Documents); + var descriptor = Documents.GetRequired(typeof(DeepDoc)); + DeepDoc Chain(int links) + { + var head = new DeepDoc(); + var tip = head; + for (var i = 0; i < links; i++) tip = tip.Inner = new DeepDoc(); + return head; + } + + // Each link is one array; the root is depth 1, so 255 links is depth 256 (at the limit) and 256 links is 257. + Assert.That(registry.ToRawRecord(descriptor, new CultRecordKey("a"), Chain(255), "1970-01-01T00:00:00.000Z").Payload, Is.Not.Empty); + var refused = Assert.Throws( + () => registry.ToRawRecord(descriptor, new CultRecordKey("a"), Chain(256), "1970-01-01T00:00:00.000Z"))!; + Assert.That(refused.Message, Does.Contain("refused on write")); + Assert.That(refused.Message, Does.Contain(typeof(DeepDoc).FullName)); + } } } From 4ebef4f023dc796df64f2e52287cc2a67a76fa1a Mon Sep 17 00:00:00 2001 From: Libby Date: Thu, 12 Nov 2026 19:42:55 +0000 Subject: [PATCH 62/93] cultmesh-py: pin the message_invalid answer on a tailed put under an unknown schema id (#143) --- .../tests/test_remote_write_authority.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/cultmesh-py/tests/test_remote_write_authority.py b/packages/cultmesh-py/tests/test_remote_write_authority.py index cafb163f..3844e589 100644 --- a/packages/cultmesh-py/tests/test_remote_write_authority.py +++ b/packages/cultmesh-py/tests/test_remote_write_authority.py @@ -180,6 +180,21 @@ class RemoteWriteAuthorityTests(unittest.TestCase): reply = self.send(granting, unowned) self.assertEqual(reply[0]["code"], "unregistered_document_put", "a granted put still meets the store's refusal") + def test_a_put_with_a_payload_tail_and_an_unknown_schema_id_is_refused_whole_under_the_canonical_id(self) -> None: + # The payload head names the registered schema, so the put routes by stamp; the tail makes the payload more + # than one MessagePack value. It is answered message_invalid (not unregistered_document_put), nothing is + # stored, and the authority is asked under the canonical id, not the id as sent. + authority = Recording(lambda _: CultMeshRemoteWriteDecision.grant()) + server = self.build(authority) + tailed = put_of(KEY_A, "x", schema_id="sha256:nobody-owns-this") + tailed["document"]["payload"] = bytes(tailed["document"]["payload"]) + b"\x01" + + replies = self.send(server, tailed) + + self.assertEqual([(reply["code"], reply["details"]["field"]) for reply in replies], [("message_invalid", "document")]) + self.assertIsNone(self.text_of(server, KEY_A)) + self.assertEqual([request.schema_id for request in authority.requests], [SCHEMA_ID]) + def test_an_authority_that_raises_refuses_and_is_logged_as_an_error(self) -> None: def explode(_: Any) -> Any: raise RuntimeError("authority exploded") From 60e50b1cb7800aa39cc3eafcea9dc9dc014f4d36 Mon Sep 17 00:00:00 2001 From: Libby Date: Thu, 12 Nov 2026 20:22:23 +0000 Subject: [PATCH 63/93] Range check: build each package's own tagged version too; cultnet-ts 0.4.0 (#144) --- package-lock.json | 6 +- package.json | 2 +- packages/cultmesh-browser/package.json | 2 +- packages/cultmesh-ts/package.json | 2 +- packages/cultnet-ts/package.json | 2 +- scripts/test-declared-dependency-ranges.mjs | 10 +-- .../test-declared-dependency-ranges.test.mjs | 64 +++++++++++++++++++ 7 files changed, 77 insertions(+), 11 deletions(-) create mode 100644 scripts/test-declared-dependency-ranges.test.mjs diff --git a/package-lock.json b/package-lock.json index 0a1558da..e5fbf13b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1205,7 +1205,7 @@ "license": "MIT", "dependencies": { "@gamecult/cultcache-ts": "^0.15.0", - "@gamecult/cultnet-ts": "^0.3.0" + "@gamecult/cultnet-ts": "^0.4.0" }, "devDependencies": { "@gamecult/cultmesh-ts": "^0.2.0", @@ -1222,7 +1222,7 @@ "license": "MIT", "dependencies": { "@gamecult/cultcache-ts": "^0.15.0", - "@gamecult/cultnet-ts": "^0.3.0", + "@gamecult/cultnet-ts": "^0.4.0", "koffi": "3.3.0" }, "devDependencies": { @@ -1236,7 +1236,7 @@ }, "packages/cultnet-ts": { "name": "@gamecult/cultnet-ts", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "dependencies": { "@gamecult/cultcache-ts": "^0.15.0", diff --git a/package.json b/package.json index a79661f2..1f883fa1 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "scripts": { "build:ts": "node scripts/run-typescript-workspaces.mjs build", "test:ts": "node scripts/run-typescript-workspaces.mjs test", - "test:pack:ts": "node scripts/test-typescript-package-closure.mjs && node scripts/test-declared-dependency-ranges.mjs", + "test:pack:ts": "node --test scripts/test-declared-dependency-ranges.test.mjs && node scripts/test-typescript-package-closure.mjs && node scripts/test-declared-dependency-ranges.mjs", "test:eve-browser-network": "node scripts/verify-eve-browser-network.mjs" }, "devDependencies": { diff --git a/packages/cultmesh-browser/package.json b/packages/cultmesh-browser/package.json index 0984ae6d..ab770f8c 100644 --- a/packages/cultmesh-browser/package.json +++ b/packages/cultmesh-browser/package.json @@ -42,7 +42,7 @@ }, "dependencies": { "@gamecult/cultcache-ts": "^0.15.0", - "@gamecult/cultnet-ts": "^0.3.0" + "@gamecult/cultnet-ts": "^0.4.0" }, "devDependencies": { "@types/node": "^24.0.0", diff --git a/packages/cultmesh-ts/package.json b/packages/cultmesh-ts/package.json index 39e0abba..7f2f20b6 100644 --- a/packages/cultmesh-ts/package.json +++ b/packages/cultmesh-ts/package.json @@ -50,7 +50,7 @@ }, "dependencies": { "@gamecult/cultcache-ts": "^0.15.0", - "@gamecult/cultnet-ts": "^0.3.0", + "@gamecult/cultnet-ts": "^0.4.0", "koffi": "3.3.0" }, "devDependencies": { diff --git a/packages/cultnet-ts/package.json b/packages/cultnet-ts/package.json index 60c9e13f..27ba3be4 100644 --- a/packages/cultnet-ts/package.json +++ b/packages/cultnet-ts/package.json @@ -1,6 +1,6 @@ { "name": "@gamecult/cultnet-ts", - "version": "0.3.0", + "version": "0.4.0", "description": "CultNet-style typed direct-pipe messaging and state replication for TypeScript runtimes.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/scripts/test-declared-dependency-ranges.mjs b/scripts/test-declared-dependency-ranges.mjs index b528fb75..40935e39 100644 --- a/scripts/test-declared-dependency-ranges.mjs +++ b/scripts/test-declared-dependency-ranges.mjs @@ -22,6 +22,7 @@ import { fileURLToPath } from "node:url"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const rootRequire = createRequire(join(repoRoot, "package.json")); const ts = rootRequire("typescript"); +const npmCli = join(npmRoot(), "bin", "npm-cli.js"); const semver = createRequire(join(npmRoot(), "package.json"))("semver"); // npm ships semver, so the check needs no dependency of its own. npm's root is beside the node binary on Windows and under lib/ on Unix. @@ -84,16 +85,17 @@ function sourceFiles(directory) { }); } -// Builds the package as the tag released it, in a scratch directory that borrows the workspace's node_modules. +// Builds the package as the tag released it (with the repo-level contracts its generator reads), in a scratch directory that borrows the workspace's node_modules. function buildRelease(directory, tag, scratch) { const archive = join(scratch, `${tag}.tar`); - git("archive", "--format=tar", "-o", archive, tag, `packages/${directory}`); + git("archive", "--format=tar", "-o", archive, tag, `packages/${directory}`, "contracts"); const extracted = join(scratch, tag); mkdirSync(extracted); execFileSync("tar", ["-xf", archive, "-C", extracted]); symlinkSync(join(repoRoot, "node_modules"), join(extracted, "node_modules"), "junction"); const packageRoot = join(extracted, "packages", directory); - execFileSync(process.execPath, [rootRequire.resolve("typescript/bin/tsc"), "-p", "tsconfig.json"], { cwd: packageRoot, stdio: "inherit" }); + // The package's own build, which is how a release was made: cultnet-ts generates its contracts module first. + execFileSync(process.execPath, [npmCli, "run", "build", "--silent"], { cwd: packageRoot, stdio: "inherit" }); return rootRequire(join(packageRoot, "dist", "index.js")); } @@ -127,7 +129,7 @@ try { const prefix = `${dependency.directory}-v`; const admitted = tags.filter((tag) => { const version = tag.startsWith(prefix) ? tag.slice(prefix.length) : undefined; - return version !== undefined && semver.valid(version) !== null && semver.satisfies(version, range) && version !== dependency.manifest.version; + return version !== undefined && semver.valid(version) !== null && semver.satisfies(version, range); }); for (const tag of admitted) { if (!built.has(tag)) { diff --git a/scripts/test-declared-dependency-ranges.test.mjs b/scripts/test-declared-dependency-ranges.test.mjs new file mode 100644 index 00000000..10e6341d --- /dev/null +++ b/scripts/test-declared-dependency-ranges.test.mjs @@ -0,0 +1,64 @@ +// test-declared-dependency-ranges.mjs against a fixture repo: a dependency that gained an export a dependent imports, +// while its manifest still carries the version of its latest release tag, must fail the check, because a consumer's +// range admits that tagged release and it lacks the export. Bumping the version and the range makes it pass. +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { copyFileSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const script = join(repoRoot, "scripts", "test-declared-dependency-ranges.mjs"); + +const write = (root, path, text) => { + mkdirSync(dirname(join(root, path)), { recursive: true }); + writeFileSync(join(root, path), text); +}; +const git = (root, ...args) => execFileSync("git", ["-c", "user.name=t", "-c", "user.email=t@t", ...args], { cwd: root, stdio: "pipe" }); +const manifest = (name, version, extra = {}) => + JSON.stringify({ name: `@t/${name}`, version, scripts: { build: "node ../../node_modules/typescript/bin/tsc -p tsconfig.json" }, ...extra }); +const tsconfig = JSON.stringify({ compilerOptions: { outDir: "dist", module: "commonjs", target: "es2020", declaration: false }, include: ["src"] }); + +function fixture() { + const root = mkdtempSync(join(tmpdir(), "declared-ranges-fixture-")); + write(root, "package.json", JSON.stringify({ name: "fixture", private: true, workspaces: ["packages/a", "packages/b"] })); + write(root, "contracts/.keep", ""); + write(root, "packages/a/package.json", manifest("a", "1.0.0")); + write(root, "packages/a/tsconfig.json", tsconfig); + write(root, "packages/a/src/index.ts", "export const foo = 1;\n"); + write(root, "packages/b/package.json", manifest("b", "1.0.0", { dependencies: { "@t/a": "^1.0.0" } })); + write(root, "packages/b/tsconfig.json", tsconfig); + write(root, "packages/b/src/index.ts", 'import { foo } from "@t/a";\nexport const b = foo;\n'); + mkdirSync(join(root, "scripts")); + copyFileSync(script, join(root, "scripts", "test-declared-dependency-ranges.mjs")); + symlinkSync(join(repoRoot, "node_modules"), join(root, "node_modules"), "junction"); + git(root, "init", "-q", "-b", "main"); + git(root, "add", "package.json", "contracts", "packages", "scripts"); + git(root, "commit", "-q", "-m", "release a 1.0.0"); + git(root, "tag", "a-v1.0.0"); + return root; +} + +const check = (root) => spawnSync(process.execPath, [join(root, "scripts", "test-declared-dependency-ranges.mjs")], { cwd: root, encoding: "utf8" }); + +test("a dependency that gained an export a dependent imports, still at its latest tag's version, fails the check", () => { + const root = fixture(); + try { + assert.equal(check(root).status, 0, "the fixture passes before the export is added"); + + write(root, "packages/a/src/index.ts", "export const foo = 1;\nexport const bar = 2;\n"); + write(root, "packages/b/src/index.ts", 'import { foo, bar } from "@t/a";\nexport const b = foo + bar;\n'); + const stale = check(root); + assert.notEqual(stale.status, 0, stale.stdout); + assert.match(stale.stderr, /a-v1\.0\.0 does not export bar/u); + + write(root, "packages/a/package.json", manifest("a", "1.1.0")); + write(root, "packages/b/package.json", manifest("b", "1.0.0", { dependencies: { "@t/a": "^1.1.0" } })); + const bumped = check(root); + assert.equal(bumped.status, 0, bumped.stderr); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); From 47e8e95d867cf4a16c70052c84916f1a91547a13 Mon Sep 17 00:00:00 2001 From: Libby Date: Thu, 12 Nov 2026 20:43:56 +0000 Subject: [PATCH 64/93] cultcache-py: pin the variant row of extra-slot-store.cc as a refused store (#145) --- .../cultcache-py/tests/test_slot_readonly.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/cultcache-py/tests/test_slot_readonly.py b/packages/cultcache-py/tests/test_slot_readonly.py index 77d4a989..68e389f3 100644 --- a/packages/cultcache-py/tests/test_slot_readonly.py +++ b/packages/cultcache-py/tests/test_slot_readonly.py @@ -16,6 +16,7 @@ from cultcache_py import ( SlotCodec, SchemaConflictError, SingleFileMessagePackBackingStore, + StoreUnreadableError, UnionArm, UnreadSlots, database_entry_field, @@ -178,6 +179,22 @@ class StoreRowTests(unittest.TestCase): return slot_unit_document return declaring_document if store == "extra-slot-consent.cc" else surface_document + def test_the_variant_row_is_refused_as_a_store_not_walked_and_the_rest_of_the_store_reads_without_it(self) -> None: + # This runtime holds no variant records (ruling: refuse, not carry; the reference's marked-variant row, the one + # row with a flattenRefusedBy, is the C# reader's). A five-slot persisted record makes the whole store + # unreadable, the file is left as it was, and the store without exactly that row reads. + variants = [row for row in _rows() if row[8] is not None] + self.assertEqual([(row[0], row[1], row[8]) for row in variants], [("extra-slot-store.cc", "marked-variant", "marked")]) + path = self.tmp / "extra-slot-store.cc" + shutil.copyfile(VECTORS / "extra-slot-store.cc", path) + before = path.read_bytes() + with self.assertRaises(StoreUnreadableError): + _open(path, surface_document, slot_unit_document) + self.assertEqual(path.read_bytes(), before) + _, cache = self._store("extra-slot-store.cc") + self.assertIsNone(cache.get(surface_document, "marked-variant")) + self.assertIsNotNone(cache.get(surface_document, "marked")) + def test_every_row_is_writable_exactly_where_the_vector_says_and_refuses_with_its_mark(self) -> None: walked = 0 for store, key, writable, explained, type_name, carried, declared, occurrences, flatten in _rows(): From 1045914701fb9e60105198124ea65d6a1c0a1a18 Mon Sep 17 00:00:00 2001 From: Libby Date: Thu, 12 Nov 2026 21:21:41 +0000 Subject: [PATCH 65/93] cultnet-ts 0.4.0 release notes, range check in the npm publish gate, fixture closes build-path gaps (#146) --- packages/cultmesh-browser/CHANGELOG.md | 4 ++++ packages/cultmesh-ts/CHANGELOG.md | 2 ++ packages/cultnet-ts/CHANGELOG.md | 2 +- scripts/check-changelog-semver.test.mjs | 6 ++++-- scripts/release-after-suite.mjs | 12 +++++++++-- .../test-declared-dependency-ranges.test.mjs | 21 ++++++++++++------- 6 files changed, 35 insertions(+), 12 deletions(-) diff --git a/packages/cultmesh-browser/CHANGELOG.md b/packages/cultmesh-browser/CHANGELOG.md index ccda16a0..e55344ba 100644 --- a/packages/cultmesh-browser/CHANGELOG.md +++ b/packages/cultmesh-browser/CHANGELOG.md @@ -9,6 +9,10 @@ All notable changes to `@gamecult/cultmesh-browser` are recorded here, following - The package is renamed `cultmesh-browser` to `@gamecult/cultmesh-browser` (the repo already scopes `@gamecult/cultcache-ts`), so the unscoped name cannot be claimed by someone else. Import from `@gamecult/cultmesh-browser` and its subpaths; the API is unchanged. Depends on `@gamecult/cultnet-ts`; the interop test uses `@gamecult/cultmesh-ts`. +### Dependencies + +- Needs `@gamecult/cultnet-ts` `^0.4.0` (was `^0.3.0`), moving with the `cultnet-ts` 0.4.0 release. + ## [0.4.0] ### Added diff --git a/packages/cultmesh-ts/CHANGELOG.md b/packages/cultmesh-ts/CHANGELOG.md index 5df85ad3..e9384f84 100644 --- a/packages/cultmesh-ts/CHANGELOG.md +++ b/packages/cultmesh-ts/CHANGELOG.md @@ -29,6 +29,8 @@ All notable changes to `@gamecult/cultmesh-ts` are recorded here, following - Needs the cultcache-ts release that ships with this change (its manifest range moves with that release): it imports `schemaIdentityOf` and `SchemaConflictError`, which 0.14.x does not export. +- Needs `@gamecult/cultnet-ts` `^0.4.0` (was `^0.3.0`): this package imports names that `cultnet-ts` 0.3.0 does not export (`cultNetMessageInvalidError`, `cultNetTextError`, `cultNetWriteNotAuthorizedError`, `WriteTracker`, `CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS`, `helloAdvertisesWriteReceipts`, `resultWindowMs`). The declared-range check (`npm run test:pack:ts`) found the old range admitting a release that lacks them. + ## [0.2.0] ### Added diff --git a/packages/cultnet-ts/CHANGELOG.md b/packages/cultnet-ts/CHANGELOG.md index cd5a32f2..44d3d5ec 100644 --- a/packages/cultnet-ts/CHANGELOG.md +++ b/packages/cultnet-ts/CHANGELOG.md @@ -3,7 +3,7 @@ All notable changes to `@gamecult/cultnet-ts` are recorded here, following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). -## Unreleased +## [0.4.0] ### Breaking diff --git a/scripts/check-changelog-semver.test.mjs b/scripts/check-changelog-semver.test.mjs index 39c07799..3773136d 100644 --- a/scripts/check-changelog-semver.test.mjs +++ b/scripts/check-changelog-semver.test.mjs @@ -857,7 +857,7 @@ test("workflow: each job releases only through release-after-suite.mjs", () => { }); test("release-after-suite checks the tag, then runs the suite, the semver check and the package's tests, and releases only when all passed", async () => { - const { releaseAfterSuite, suite, checker } = await import("./release-after-suite.mjs"); + const { releaseAfterSuite, suite, checker, rangeCheck } = await import("./release-after-suite.mjs"); const npmVersion = JSON.parse(readFileSync(join(repoRoot, "packages", "cultcache-ts", "package.json"), "utf8")).version; const npmDir = join(repoRoot, "packages", "cultcache-ts"); const pyDir = join(repoRoot, "packages", "cultnet-py"); @@ -881,13 +881,15 @@ test("release-after-suite checks the tag, then runs the suite, the semver check const npmCalls = [ suiteCall, checkerCall("@gamecult/cultcache-ts", npmVersion), + ["npm", ["run", "build:ts"], repoRoot, checked], + [process.execPath, [rangeCheck], repoRoot, checked], ["npm", ["test"], npmDir, checked], ["npm", ["publish", "--access", "public"], npmDir, env], ]; assert.equal(go(["npm-publish", npmDir, npmTag]), 0); assert.deepEqual(calls, npmCalls); // any check that fails stops the release at that check - for (const fail of [0, 1, 2]) { + for (const fail of [0, 1, 2, 3, 4]) { assert.equal(go(["npm-publish", npmDir, npmTag], fail), 1, `check ${fail}`); assert.deepEqual(calls, npmCalls.slice(0, fail + 1), `check ${fail}`); } diff --git a/scripts/release-after-suite.mjs b/scripts/release-after-suite.mjs index 7832b23c..a7f372ca 100644 --- a/scripts/release-after-suite.mjs +++ b/scripts/release-after-suite.mjs @@ -1,7 +1,9 @@ // How publish-packages.yml releases a package. From this one process, in order: the release tag must name // the package's declared prefix and its manifest version, the release-checks suite must pass, the semver -// policy check must pass for that version, the package's own tests must pass, and only then the release -// action runs. No workflow condition can then release what any of those checks did not pass. +// policy check must pass for that version, an npm release's declared dependency ranges must hold (the +// workspace is built, then every tagged release a sibling's range admits is checked against what the +// sibling imports), the package's own tests must pass, and only then the release action runs. No workflow +// condition can then release what any of those checks did not pass. // docs/semver-policy.md, "The suite gates every release". // // node scripts/release-after-suite.mjs npm-publish npm publish --access public in @@ -18,6 +20,7 @@ import releasePackages from "./release-packages.mjs"; export const suite = fileURLToPath(new URL("./check-changelog-semver.test.mjs", import.meta.url)); export const checker = fileURLToPath(new URL("./check-changelog-semver.mjs", import.meta.url)); +export const rangeCheck = fileURLToPath(new URL("./test-declared-dependency-ranges.mjs", import.meta.url)); const repoRoot = fileURLToPath(new URL("..", import.meta.url)); // The checks run without the variables that can make `node --test` run nothing and exit 0 @@ -34,6 +37,8 @@ export const actions = { "npm-publish": { manifest: (dir) => JSON.parse(readFileSync(join(dir, "package.json"), "utf8")), test: (dir) => ["npm", ["test"], dir], + // The range check builds each tagged release against the workspace's built dists, so it follows a workspace build. + ranges: [["npm", ["run", "build:ts"], repoRoot], [process.execPath, [rangeCheck], repoRoot]], release: (dir) => ["npm", ["publish", "--access", "public"], dir], tagOnly: true, }, @@ -83,6 +88,9 @@ export function releaseAfterSuite(argv, run = spawn, env = process.env) { if (isTag && run(process.execPath, [checker, "--package", manifest.name, "--version", manifest.version, "--cwd", repoRoot], undefined, clean) !== 0) { return refuse(`${manifest.name} ${manifest.version} fails the semver policy (docs/semver-policy.md)`); } + for (const [command, args, cwd] of isTag ? action.ranges ?? [] : []) { + if (run(command, args, cwd, clean) !== 0) return refuse(`${manifest.name}'s declared dependency ranges do not hold (scripts/test-declared-dependency-ranges.mjs)`); + } if (run(...action.test(dir), clean) !== 0) return refuse(`${manifest.name}'s own tests failed`); return run(...action.release(dir), env); } diff --git a/scripts/test-declared-dependency-ranges.test.mjs b/scripts/test-declared-dependency-ranges.test.mjs index 10e6341d..644bfc1a 100644 --- a/scripts/test-declared-dependency-ranges.test.mjs +++ b/scripts/test-declared-dependency-ranges.test.mjs @@ -1,6 +1,8 @@ // test-declared-dependency-ranges.mjs against a fixture repo: a dependency that gained an export a dependent imports, // while its manifest still carries the version of its latest release tag, must fail the check, because a consumer's // range admits that tagged release and it lacks the export. Bumping the version and the range makes it pass. +// The fixture package a builds like cultnet-ts: its build script first generates a module from the repo-level contracts/ +// directory, so a tag built by a bare tsc, or archived without contracts/, fails here as it does on the real tree. import assert from "node:assert/strict"; import { execFileSync, spawnSync } from "node:child_process"; import { copyFileSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; @@ -17,17 +19,22 @@ const write = (root, path, text) => { writeFileSync(join(root, path), text); }; const git = (root, ...args) => execFileSync("git", ["-c", "user.name=t", "-c", "user.email=t@t", ...args], { cwd: root, stdio: "pipe" }); -const manifest = (name, version, extra = {}) => - JSON.stringify({ name: `@t/${name}`, version, scripts: { build: "node ../../node_modules/typescript/bin/tsc -p tsconfig.json" }, ...extra }); +const tsc = "node ../../node_modules/typescript/bin/tsc -p tsconfig.json"; +const manifest = (name, version, extra = {}, build = tsc) => + JSON.stringify({ name: `@t/${name}`, version, scripts: { build }, ...extra }); +const generated = "node tools/generate.mjs && " + tsc; const tsconfig = JSON.stringify({ compilerOptions: { outDir: "dist", module: "commonjs", target: "es2020", declaration: false }, include: ["src"] }); function fixture() { const root = mkdtempSync(join(tmpdir(), "declared-ranges-fixture-")); write(root, "package.json", JSON.stringify({ name: "fixture", private: true, workspaces: ["packages/a", "packages/b"] })); - write(root, "contracts/.keep", ""); - write(root, "packages/a/package.json", manifest("a", "1.0.0")); + write(root, "contracts/value.json", "2"); + write(root, "packages/a/tools/generate.mjs", + 'import { readFileSync, writeFileSync } from "node:fs";\n' + + 'writeFileSync("src/data.ts", `export const contractValue = ${readFileSync("../../contracts/value.json", "utf8")};\\n`);\n'); + write(root, "packages/a/package.json", manifest("a", "1.0.0", {}, generated)); write(root, "packages/a/tsconfig.json", tsconfig); - write(root, "packages/a/src/index.ts", "export const foo = 1;\n"); + write(root, "packages/a/src/index.ts", 'export { contractValue } from "./data";\nexport const foo = 1;\n'); write(root, "packages/b/package.json", manifest("b", "1.0.0", { dependencies: { "@t/a": "^1.0.0" } })); write(root, "packages/b/tsconfig.json", tsconfig); write(root, "packages/b/src/index.ts", 'import { foo } from "@t/a";\nexport const b = foo;\n'); @@ -48,13 +55,13 @@ test("a dependency that gained an export a dependent imports, still at its lates try { assert.equal(check(root).status, 0, "the fixture passes before the export is added"); - write(root, "packages/a/src/index.ts", "export const foo = 1;\nexport const bar = 2;\n"); + write(root, "packages/a/src/index.ts", 'export { contractValue } from "./data";\nexport const foo = 1;\nexport const bar = 2;\n'); write(root, "packages/b/src/index.ts", 'import { foo, bar } from "@t/a";\nexport const b = foo + bar;\n'); const stale = check(root); assert.notEqual(stale.status, 0, stale.stdout); assert.match(stale.stderr, /a-v1\.0\.0 does not export bar/u); - write(root, "packages/a/package.json", manifest("a", "1.1.0")); + write(root, "packages/a/package.json", manifest("a", "1.1.0", {}, generated)); write(root, "packages/b/package.json", manifest("b", "1.0.0", { dependencies: { "@t/a": "^1.1.0" } })); const bumped = check(root); assert.equal(bumped.status, 0, bumped.stderr); From 25f21fcc53c7be8d7e7fe1884aa24910e74520bf Mon Sep 17 00:00:00 2001 From: Libby Date: Thu, 12 Nov 2026 21:46:59 +0000 Subject: [PATCH 66/93] cultcache-ts: a pushAll through a dangling link is refused like push, not replaced (#147) --- docs/document-variants-cut.md | 4 +-- docs/write-ownership-map.md | 4 +-- packages/cultcache-rs/src/lib.rs | 2 +- packages/cultcache-ts/CHANGELOG.md | 3 ++- .../single-file-messagepack-backing-store.ts | 25 ++++++------------- packages/cultcache-ts/test/cult-cache.test.ts | 7 +++--- 6 files changed, 18 insertions(+), 27 deletions(-) diff --git a/docs/document-variants-cut.md b/docs/document-variants-cut.md index e9dc51ed..f363521f 100644 --- a/docs/document-variants-cut.md +++ b/docs/document-variants-cut.md @@ -1620,7 +1620,7 @@ The one door per runtime, and who walks through it: - **Shared paths.** Open, flush, commit, `ReadDurable`, `ReplaceDurable`. Rust `pull_all` RS:1243 also checks `is_absent` on the store and on the lock path before it takes the shared lock; that skips lock creation only, and the verdict stays with the reader. - **Cut line.** `merge-r1` h2 (C#) and h4/h6 (TS `readStoreBytes`); `mesh-single-file-reader` h1. - **Pinned by.** C#: `StoreReadabilityTests` `ADanglingFileLinkIsAnIoErrorOnOpenNotAnEmptyStore` :208, `ADanglingDirectoryLinkIsAnIoErrorOnOpenNotAnEmptyStore` :185, and the commit variants :196, :219. Mesh: `ADanglingLinkIsAnIoErrorOnTypedWriteRawWriteAndReadNeverAMissingFile` :107 and `OnlyNothingAtThePathIsAMissingFile` :134 (`CultMeshSingleFileStoreTests.cs`). Rust: `a_dangling_symlink_at_the_store_path_is_an_error_and_nothing_is_written` RS:5832. TS: "readStoreBytes reports nothing at the path only when the lstat finds nothing too". -- **Known gaps.** The writer side still tolerates a dangling link: Rust `write_all_unlocked` RS:992-996 and TS `pushAll` TS:94-100 replace it on a whole-store write (`variants:follow_up:writer-dangling-link-tolerance`, owner `c2a-write-set`). What a replace does to a live link is `variants:follow_up:store-replace-cut` item 2. Windows junction behavior was proven at `42f05f66` and `b1ff2dcb`, not at the final head. +- **Known gaps.** The writer no longer tolerates a dangling link: the reader's refusal stands for every write, whole-store `pushAll` included (Rust `a_push_all_through_a_dangling_link_is_refused_like_push_and_writes_nothing`, TS `a pushAll through a dangling link is refused like push`; `variants:follow_up:writer-dangling-link-tolerance`). What a replace does to a live link is `variants:follow_up:store-replace-cut` item 2. Windows junction behavior was proven at `42f05f66` and `b1ff2dcb`, not at the final head. ### C. Zero-byte file @@ -1672,7 +1672,7 @@ The one door per runtime, and who walks through it: - **Shared paths.** TS `pushAll` and Rust `push_all` only. - **Cut line.** `merge-r1` h4 (TS) and h6 (Rust). - **Pinned by.** The manifest walks run a soft flush over every vector in TS (the `softPushAll` operation, TS test :2336) and Rust (`soft_push_all`, RS:5149), each with an absent and a dangling-link case; `a_soft_push_all_writes_where_nothing_is_and_refuses_a_dangling_link` RS:6019. -- **Known gaps.** C# and Python have no soft decision, and the C# Mesh flag is dead; no follow-up is registered (h6 `undone` names it). `writer-dangling-link-tolerance` applies to the non-soft branch. +- **Known gaps.** C# and Python have no soft decision, and the C# Mesh flag is dead; no follow-up is registered (h6 `undone` names it). `writer-dangling-link-tolerance` is settled: the non-soft branch refuses a dangling link too. ### What no cut owns yet diff --git a/docs/write-ownership-map.md b/docs/write-ownership-map.md index 44452dc6..79140c2b 100644 --- a/docs/write-ownership-map.md +++ b/docs/write-ownership-map.md @@ -107,8 +107,8 @@ Authority map (also in the spec): - `variants:follow_up:public-store-writes-behind-cache`: **leave**. It asks who may stage behind an attached cache (API shape); this cut changes what any staging is judged by. No collision: direct `Push`/`CommitBatch` reach the same on-disk judge, so they get the per-key rule for free. Hands must not give direct staging a different predicate. -- `variants:follow_up:writer-dangling-link-tolerance`: **leave**. Rust/TS whole-store `push_all` reading a dangling link is - a reader/writer tolerance question, not ownership; this cut touches no leaf. +- `variants:follow_up:writer-dangling-link-tolerance`: **settled** (the reader owns the verdict; every runtime's whole-store + `push_all` refuses a dangling link, as `push` does). ## Deferred doc findings this cut fixes diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index 678ff4d6..b3d6a441 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -4120,7 +4120,7 @@ struct DiskStore { } impl DiskStore { - /// A file that is gone, or a dangling link a whole-file replace is about to replace: an empty v1 store. + /// Nothing at the path: an empty v1 store. A dangling link is something at the path and is refused, not read as this. fn absent() -> Self { Self { absent: true, format: STORE_FORMAT_V1, envelopes: Vec::new(), catalog: Vec::new() } } diff --git a/packages/cultcache-ts/CHANGELOG.md b/packages/cultcache-ts/CHANGELOG.md index dcbfd12e..fea670db 100644 --- a/packages/cultcache-ts/CHANGELOG.md +++ b/packages/cultcache-ts/CHANGELOG.md @@ -7,7 +7,8 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following ### Breaking -- `SingleFileMessagePackBackingStore.pushAll` writes the given entries onto the store as it is under the lock, as `push` does: a record under the same key is replaced and no other is removed. It used to replace the whole file with the entries, so `CultMesh.flush()` from a cache that held less than the store (a node created with `pullOnStart: false`, or one whose image was stale) wiped the records it never read. A caller that relied on `pushAll` to reset a store removes the file first. A soft flush and the dangling-link case are unchanged. `CultCache.storedSnapshot()` is the image as the stores hold it (a global loaded from a legacy key under that key) and is what `CultMesh.flush` persists, so a flush no longer leaves such a global under two keys; `snapshot()` is unchanged. +- `SingleFileMessagePackBackingStore.pushAll` through a dangling symbolic link at the store path is refused with `ENOENT`, as `push` and `pullAll` are, and writes nothing; it used to replace the link with a new store. The reader owns the verdict on what is at the path. +- `SingleFileMessagePackBackingStore.pushAll` writes the given entries onto the store as it is under the lock, as `push` does: a record under the same key is replaced and no other is removed. It used to replace the whole file with the entries, so `CultMesh.flush()` from a cache that held less than the store (a node created with `pullOnStart: false`, or one whose image was stale) wiped the records it never read. A caller that relied on `pushAll` to reset a store removes the file first. A soft flush is unchanged. `CultCache.storedSnapshot()` is the image as the stores hold it (a global loaded from a legacy key under that key) and is what `CultMesh.flush` persists, so a flush no longer leaves such a global under two keys; `snapshot()` is unchanged. - A single-file store marked `cultcache.store.v3` (it holds an element id, written by the C# runtime) is read, and a rewrite keeps the mark and the ids; this runtime does not decide when a document carries ids. A runtime from before this change refuses a v3 store by name, so rolling back to an older release after a store has been written as v3 is one-way: there is no carry-back, so restore from a copy taken before upgrading. - `indexes` are unique, as C# `[CultIndex]`: a write or a load that leaves two records of a type holding one non-blank value under one alias is refused naming both keys and the alias, and writes nothing. Index text is the C# canonical diff --git a/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts b/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts index 298f1bee..b5199ae3 100644 --- a/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts +++ b/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts @@ -87,26 +87,15 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore { // another writer added. The store is read by the same reader as `pullAll`, so one it would refuse (not exactly one // store, a variant, a body it cannot decode) is refused and left as it is. A soft flush asks that reader too: nothing // at the path is written, a store is left alone. - let held: CultCacheEnvelope[] = []; - try { - const disk = this.#readDisk(); - if (options.soft && !disk.absent) { - return; - } - - this.#format = disk.format; - held = disk.envelopes; - } catch (error) { - // Only a dangling link reaches here with ENOENT: it holds no store, so a flush has nothing to merge with and no - // header to keep, and replaces the link (R3 pins this). A soft flush does not replace what it cannot read, and every - // merging writer has already refused the link when it read the current entries. - if (options.soft || (error as NodeJS.ErrnoException).code !== "ENOENT") { - throw error; - } - - this.#format = STORE_FORMAT_VERSION; + // The reader owns the verdict on what is at the path: a dangling link is refused here as it is for open and push. + const disk = this.#readDisk(); + if (options.soft && !disk.absent) { + return; } + this.#format = disk.format; + const held = disk.envelopes; + // A key is held once: of same-type entries sharing a key in one batch, the last is kept. const supplied = [...new Map(entries.map((entry) => [entry.key, entry])).values()]; requireOneTypePerKey(this.filePath, entries, held); diff --git a/packages/cultcache-ts/test/cult-cache.test.ts b/packages/cultcache-ts/test/cult-cache.test.ts index 4efa6d78..7c559af7 100644 --- a/packages/cultcache-ts/test/cult-cache.test.ts +++ b/packages/cultcache-ts/test/cult-cache.test.ts @@ -2525,14 +2525,15 @@ test("R3 decides resolve-or-refuse: a push through a live link replaces the link assert.deepEqual(await readFile(target), before); }); -test("R3 decides resolve-or-refuse: a pushAll through a dangling link replaces the link", { skip: process.platform === "win32" }, async () => { +test("a pushAll through a dangling link is refused like push, and the link and its volume are left as they were", { skip: process.platform === "win32" }, async () => { const dir = await mkdtemp(join(tmpdir(), "cultcache-dangling-push-all-")); const volume = join(dir, "volume"); await mkdir(volume); const link = join(dir, "store.cc"); await symlink(join(volume, "store.cc"), link); - await new SingleFileMessagePackBackingStore(link).pushAll([{ key: "a", type: "t", payload: documentPayloadFromBytes(Uint8Array.of(0x90)), storedAt: "2026-09-30T00:00:00Z" }]); - assert.ok((await lstat(link)).isFile()); + const envelope = { key: "a", type: "t", payload: documentPayloadFromBytes(Uint8Array.of(0x90)), storedAt: "2026-09-30T00:00:00Z" }; + await assert.rejects(() => new SingleFileMessagePackBackingStore(link).pushAll([envelope]), (error: NodeJS.ErrnoException) => error.code === "ENOENT"); + assert.ok((await lstat(link)).isSymbolicLink()); assert.deepEqual(await readdir(volume), []); }); From f1e72adf871d1ce4e884387b44701b66429e92c3 Mon Sep 17 00:00:00 2001 From: Libby Date: Thu, 12 Nov 2026 22:40:27 +0000 Subject: [PATCH 67/93] Python: store replace on Windows tries POSIX-semantics rename first (store-windows-py) (#149) --- .../cultcache-py/src/cultcache_py/stores.py | 106 ++++++++++++++- .../cultcache-py/tests/test_store_replace.py | 124 ++++++++++++++++++ 2 files changed, 228 insertions(+), 2 deletions(-) create mode 100644 packages/cultcache-py/tests/test_store_replace.py diff --git a/packages/cultcache-py/src/cultcache_py/stores.py b/packages/cultcache-py/src/cultcache_py/stores.py index c15ac32e..5b9ee44e 100644 --- a/packages/cultcache-py/src/cultcache_py/stores.py +++ b/packages/cultcache-py/src/cultcache_py/stores.py @@ -130,7 +130,7 @@ class JsonLinesBackingStore: "stored_at": envelope.stored_at, }, ensure_ascii=False, sort_keys=True)) temp.write_text("\n".join(lines) + ("\n" if lines else ""), encoding="utf-8") - temp.replace(self.path) + _replace_store(temp, self.path) class SingleFileMessagePackBackingStore: @@ -323,7 +323,7 @@ def commit_store_file(path: Path, data: bytes) -> None: os.fsync(fd) finally: os.close(fd) - os.replace(staged, path) + _replace_store(staged, path) except BaseException: try: os.unlink(staged) @@ -338,6 +338,108 @@ def commit_store_file(path: Path, data: bytes) -> None: os.close(directory) +REPLACE_ATTEMPTS = 25 +REPLACE_RETRY_SECONDS = 0.02 +_ERROR_ACCESS_DENIED = 5 +_ERROR_SHARING_VIOLATION = 32 +_ERROR_LOCK_VIOLATION = 33 +_POSIX_UNSUPPORTED = (1, 50, 87) # incorrect function, not supported, invalid parameter: FAT, exFAT, before Windows 10 1607 +MOVEFILE_REPLACE_WRITE_THROUGH = 0x9 # MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH + + +class ReplaceKernel: + """The kernel32 calls a Windows store replace needs, each answering 0 or a Win32 error code.""" + + def rename_posix(self, staged: str, destination: str) -> int: + """CreateFileW(staged, DELETE|SYNCHRONIZE), then FileRenameInfoEx with REPLACE_IF_EXISTS|POSIX_SEMANTICS, then CloseHandle.""" + raise NotImplementedError + + def move_file_ex(self, staged: str, destination: str, flags: int) -> int: + raise NotImplementedError + + def sleep(self, seconds: float) -> None: + time.sleep(seconds) + + +def _replace_store(staged: str | os.PathLike[str], destination: str | os.PathLike[str]) -> None: + """Replaces `destination` with `staged`: POSIX-semantics rename first on Windows (so a reader holding the store open + never sees it absent), os.replace elsewhere.""" + if sys.platform == "win32": + replace_store_windows(os.fspath(staged), os.fspath(destination), WindowsReplaceKernel()) + else: + os.replace(staged, destination) + + +def replace_store_windows(staged: str, destination: str, kernel: ReplaceKernel) -> None: + """POSIX-semantics rename, falling back for the rest of the call to write-through MoveFileExW where POSIX semantics are + unsupported; errors 5, 32 and 33 retry up to 25 times 20 ms apart, any other code raises at once. The TypeScript and C# + runtimes take the same decision table.""" + posix = True + last = 0 + for _ in range(REPLACE_ATTEMPTS): + code = kernel.rename_posix(staged, destination) if posix else kernel.move_file_ex(staged, destination, MOVEFILE_REPLACE_WRITE_THROUGH) + if posix and code in _POSIX_UNSUPPORTED: + posix = False + code = kernel.move_file_ex(staged, destination, MOVEFILE_REPLACE_WRITE_THROUGH) + if code == 0: + return + if code not in (_ERROR_ACCESS_DENIED, _ERROR_SHARING_VIOLATION, _ERROR_LOCK_VIOLATION): + raise _replace_error(f"CultCache could not replace the store: Win32 error {code}.", code) + last = code + kernel.sleep(REPLACE_RETRY_SECONDS) + raise _replace_error(f"CultCache could not replace the store after {REPLACE_ATTEMPTS} attempts: last Win32 error {last}.", last) + + +def _replace_error(message: str, code: int) -> OSError: + error = OSError(message) + error.winerror = code # type: ignore[attr-defined] + return error + + +class WindowsReplaceKernel(ReplaceKernel): + """kernel32 bound by ctypes, as _try_lock_windows binds it. Windows only.""" + + def __init__(self) -> None: + import ctypes + from ctypes import wintypes + + self._ctypes = ctypes + k = self._k = ctypes.WinDLL("kernel32", use_last_error=True) + k.CreateFileW.restype = wintypes.HANDLE + k.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, ctypes.c_void_p, wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE] + k.SetFileInformationByHandle.restype = wintypes.BOOL + k.SetFileInformationByHandle.argtypes = [wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD] + k.CloseHandle.restype = wintypes.BOOL + k.CloseHandle.argtypes = [wintypes.HANDLE] + k.MoveFileExW.restype = wintypes.BOOL + k.MoveFileExW.argtypes = [wintypes.LPCWSTR, wintypes.LPCWSTR, wintypes.DWORD] + + def rename_posix(self, staged: str, destination: str) -> int: + ctypes, k = self._ctypes, self._k + delete_synchronize, share_all, open_existing, normal = 0x00110000, 0x7, 3, 0x80 + handle = k.CreateFileW(staged, delete_synchronize, share_all, None, open_existing, normal, None) + if handle is None or ctypes.c_void_p(handle).value in (None, ctypes.c_void_p(-1).value): + return ctypes.get_last_error() + try: + # FILE_RENAME_INFO: Flags (DWORD), RootDirectory (pointer-aligned HANDLE), FileNameLength (DWORD), FileName (WCHAR[]). + name = os.path.abspath(destination).encode("utf-16-le") + pointer = ctypes.sizeof(ctypes.c_void_p) + length_at = pointer + pointer + info = ctypes.create_string_buffer(length_at + 4 + len(name) + 2) + info[0:4] = (0x3).to_bytes(4, "little") # FILE_RENAME_FLAG_REPLACE_IF_EXISTS | FILE_RENAME_FLAG_POSIX_SEMANTICS + info[length_at:length_at + 4] = len(name).to_bytes(4, "little") + info[length_at + 4:length_at + 4 + len(name)] = name + file_rename_info_ex = 22 + if k.SetFileInformationByHandle(handle, file_rename_info_ex, info, len(info)): + return 0 + return ctypes.get_last_error() + finally: + k.CloseHandle(handle) + + def move_file_ex(self, staged: str, destination: str, flags: int) -> int: + return 0 if self._k.MoveFileExW(staged, destination, flags) else self._ctypes.get_last_error() + + @contextmanager def store_write_lock(path: Path, timeout: float = LOCK_TIMEOUT_SECONDS) -> Iterator[None]: """Holds the exclusive store lock on `.lock`, the lock the C#, Rust and TypeScript runtimes take: flock on Unix, diff --git a/packages/cultcache-py/tests/test_store_replace.py b/packages/cultcache-py/tests/test_store_replace.py new file mode 100644 index 00000000..120fdc60 --- /dev/null +++ b/packages/cultcache-py/tests/test_store_replace.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import os +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from cultcache_py import SingleFileMessagePackBackingStore, stores +from cultcache_py.backing_store import CultCacheEnvelope +from cultcache_py.stores import MOVEFILE_REPLACE_WRITE_THROUGH, ReplaceKernel, replace_store_windows + + +class FakeKernel(ReplaceKernel): + def __init__(self, posix: list[int], move_ex: list[int]) -> None: + self.posix, self.move_ex = list(posix), list(move_ex) + self.log: list[str] = [] + self.sleeps: list[float] = [] + + def rename_posix(self, staged, destination): + self.log.append("posix") + return self.posix.pop(0) if self.posix else 0 + + def move_file_ex(self, staged, destination, flags): + self.log.append(f"moveex:{flags}") + return self.move_ex.pop(0) if self.move_ex else 0 + + def sleep(self, seconds): + self.sleeps.append(seconds) + + +def run(posix, move_ex): + kernel = FakeKernel(posix, move_ex) + return kernel, lambda: replace_store_windows("a", "b", kernel) + + +class WindowsReplaceTable(unittest.TestCase): + def test_falls_back_to_write_through_move_where_posix_rename_is_unsupported(self): + self.assertEqual(MOVEFILE_REPLACE_WRITE_THROUGH, 0x9) + for unsupported in (1, 50, 87): + kernel, go = run([unsupported], [0]) + go() + self.assertEqual(kernel.log, ["posix", "moveex:9"]) + + def test_once_on_move_the_rest_of_the_call_stays_there(self): + kernel, go = run([87], [32, 0]) + go() + self.assertEqual(kernel.log, ["posix", "moveex:9", "moveex:9"]) + + def test_transient_codes_retry_the_same_primitive(self): + for transient in (5, 32, 33): + kernel, go = run([transient, transient, 0], []) + go() + self.assertEqual(kernel.log, ["posix", "posix", "posix"]) + self.assertEqual(kernel.sleeps, [0.02, 0.02]) + + def test_a_working_posix_rename_is_the_whole_replace(self): + kernel, go = run([0], []) + go() + self.assertEqual(kernel.log, ["posix"]) + self.assertEqual(kernel.sleeps, []) + + def test_unsupported_after_the_fallback_is_an_error_not_another_fallback(self): + kernel, go = run([87], [32, 87]) + with self.assertRaisesRegex(OSError, "Win32 error 87") as caught: + go() + self.assertEqual(caught.exception.winerror, 87) + self.assertEqual(kernel.log, ["posix", "moveex:9", "moveex:9"]) + for unsupported in (1, 50, 87): + kernel, go = run([87], [unsupported]) + with self.assertRaisesRegex(OSError, f"Win32 error {unsupported}"): + go() + self.assertEqual(kernel.log, ["posix", "moveex:9"]) + + def test_gives_up_after_25_attempts_naming_the_last_code(self): + kernel, go = run([32] * 30, []) + with self.assertRaisesRegex(OSError, "after 25 attempts: last Win32 error 32\\."): + go() + self.assertEqual(len(kernel.log), 25) + + def test_other_codes_fail_at_once(self): + kernel, go = run([2], []) + with self.assertRaisesRegex(OSError, "Win32 error 2"): + go() + self.assertEqual(len(kernel.log), 1) + kernel, go = run([87], [3]) + with self.assertRaisesRegex(OSError, "Win32 error 3"): + go() + + +class StoreWritesRouteThroughTheReplace(unittest.TestCase): + def test_both_stores_replace_through_replace_store(self): + seen: list[tuple[Path, Path]] = [] + real = stores._replace_store + + def spy(staged, destination): + seen.append((Path(staged), Path(destination))) + real(staged, destination) + + with tempfile.TemporaryDirectory() as directory, mock.patch.object(stores, "_replace_store", spy): + envelope = CultCacheEnvelope(key="k", type="t", payload=b"\x01", stored_at="2026-11-06T00:00:00Z") + binary = Path(directory) / "s.cc" + SingleFileMessagePackBackingStore(binary).push_all([envelope]) + lines = Path(directory) / "s.jsonl" + stores.JsonLinesBackingStore(lines).push_all([envelope]) + self.assertEqual([destination for _, destination in seen], [binary, lines]) + self.assertTrue(binary.read_bytes() and lines.read_text()) + + @unittest.skipUnless(sys.platform == "win32", "needs a real Windows") + def test_real_kernel_replaces_through_both_primitives(self): + kernel = stores.WindowsReplaceKernel() + with tempfile.TemporaryDirectory() as directory: + for name, replace in (("posix", kernel.rename_posix), ("moveex", lambda s, d: kernel.move_file_ex(s, d, MOVEFILE_REPLACE_WRITE_THROUGH))): + staged, store = Path(directory, name + ".tmp"), Path(directory, name) + store.write_bytes(b"old") + staged.write_bytes(b"new") + self.assertEqual(replace(str(staged), str(store)), 0) + self.assertEqual(store.read_bytes(), b"new") + self.assertEqual(kernel.rename_posix(str(Path(directory, "missing")), str(Path(directory, "x"))), 2) + + +if __name__ == "__main__": + unittest.main() From 3a8de542de1677f29d915cbbadc91ec696d5daa0 Mon Sep 17 00:00:00 2001 From: Libby Date: Fri, 13 Nov 2026 00:01:44 +0000 Subject: [PATCH 68/93] Rust, Python, TS: a write through a live link at the store path is refused (store-link-rs-py-ts) (#150) --- packages/cultcache-py/CHANGELOG.md | 1 + .../cultcache-py/src/cultcache_py/__init__.py | 3 +- .../cultcache-py/src/cultcache_py/stores.py | 45 +++++-- packages/cultcache-py/tests/test_cultcache.py | 70 +++++++++-- packages/cultcache-rs/CHANGELOG.md | 1 + packages/cultcache-rs/README.md | 7 +- packages/cultcache-rs/src/lib.rs | 111 ++++++++++++++---- packages/cultcache-ts/CHANGELOG.md | 2 +- packages/cultcache-ts/src/index.ts | 1 + packages/cultcache-ts/src/store-file.ts | 27 ++++- packages/cultcache-ts/test/cult-cache.test.ts | 40 +++++-- packages/cultcache-ts/test/store-file.test.ts | 34 ++++++ 12 files changed, 288 insertions(+), 54 deletions(-) diff --git a/packages/cultcache-py/CHANGELOG.md b/packages/cultcache-py/CHANGELOG.md index ea3f72fd..665f9ae1 100644 --- a/packages/cultcache-py/CHANGELOG.md +++ b/packages/cultcache-py/CHANGELOG.md @@ -13,6 +13,7 @@ ### Breaking +- A write at a store path that is a link, live or dangling, is refused with the new `StoreLinkedError` (an `OSError`, exported from `cultcache_py`; `.path` names the path) before the lock and the replace, for the single-file store and for `JsonLinesBackingStore` (`push`, `push_all`, `delete`), and nothing is written. Before, a write at a live link replaced the link with a regular file, and at a dangling link both stores raised `FileNotFoundError`. A caller catching `FileNotFoundError` there now catches `StoreLinkedError` (`OSError`); configure the link's target as the store path. On Windows a junction or any reparse point counts as a link. Reads through a live link work. - A record whose payload carries a slot past its reader's declared slots, at any depth (the document, a nested record, a list element, a union arm, a unit arm), is read, held read-only, and every write or removal of its key (`put`, `put_global`, `update`, `put_envelope`, `put_envelopes`, `delete`, `delete_global`) is refused with `SchemaConflictError` before any store is touched (`cultlib-gaps:ruling:extra-trailing-slots`). Reading dropped those slots silently and the next write shed them. `SchemaConflictError.unread_slots` is an `UnreadSlots(type_name, slot_count, declared_count, occurrences, depth0_only)` and the message names the type and both counts, never a slot value. A depth-0 mark is consented, and the record writable, only when the record is stored under an id the document lists in `compatible_schema_ids` and that id's catalog entry has slots past the document's, covering the mark's slot count (`cultlib-gaps:ruling:slot-mark-consent`); a nested mark, or a mark under the document's own id, keeps the record read-only whatever is declared. Missing trailing slots keep their declared defaults. An untouched marked record keeps its stored bytes through every other write. Added `CultCache.is_writable(document, key)` (advisory; the write still decides: it also answers False for a foreign-carried key, for a key held as a type the document does not own, and for a global document at a non-global key; `delete` asks the same judges, so removing a key held as another type is refused with `SchemaConflictError` unless the document owns that record's schema id (its own or one it lists compatible), in which case the removal removes that record, its file row and its lookups, as a write of the key would replace it; it returned silently and removed nothing), `DocumentDefinition.decode_payload_marked`, and `UnreadSlots`. A document with a custom `decode` (no slot codec) never marks. A store holding a variant record is still unreadable here. Implies a minor bump while the version is 0.x (0.3.0 to 0.4.0): code that wrote back a record from a newer writer now gets `SchemaConflictError`. - A payload is one MessagePack value: `DocumentDefinition.decode_payload` refuses bytes after it, by count, with `ValueError` ` payload has N bytes after its first MessagePack value (refused on read).` (never the bytes, never the depth refusal; a payload that ends early stays the decoder's), as the C# reference does (`tests/vectors/union-parity/bytes-after-outcomes.msgpack`). It raised `msgpack.ExtraData` (a `ValueError`, so only a caller naming `msgpack.ExtraData` is affected); this is a minor-version bump while pre-1.0. The refused set is unchanged. Cache loads, `get` and every definition built on the MessagePack decoder go through it; a user `payload_decoder` that raises `ExtraData` is refused the same way. Depth is judged first, so a value nested past 256 with a tail is the depth refusal, as in C#. - A cache holds one record per key whatever its type (`variants:ruling:cache-refuses-retype`). A `put`, `put_envelope`, `put_envelopes` or `put_global` of a second type under a key the cache holds, or under a key a store carries foreign, is refused with `SchemaConflictError` naming the key, even with no store, and changes nothing; retype a key by deleting it, then putting the new type. A write may replace another registered type's record only when the written type owns its schema id (declares it or lists it compatible) and both types share one home store. A load that reads one key from two stores, whatever the types, is refused with `CultCacheError` naming the key and both types, and admits nothing. A store that holds a record no registered type claims now loads and is carried: the cache does not serve it, the store keeps its bytes through every write, `delete` or `put` at its key is refused with `SchemaConflictError`, and registering a type that declares its schema id claims it at the next load. It raised `CultCacheError` "Unknown persisted document type". Two global types share the key `__global__`, so a second global type in one cache is now refused, and a store that already holds two global types at `__global__` (a JSONL store written by caches that never pulled could produce one) no longer loads: the pull raises `CultCacheError` and admits nothing. C# refuses a second persisted global on pull the same way (`cultcache-store-composition.md`, "Globals never invented"); repair such a file by deleting one of the two records with the runtime that wrote it. diff --git a/packages/cultcache-py/src/cultcache_py/__init__.py b/packages/cultcache-py/src/cultcache_py/__init__.py index 69828bf3..a2752511 100644 --- a/packages/cultcache-py/src/cultcache_py/__init__.py +++ b/packages/cultcache-py/src/cultcache_py/__init__.py @@ -19,7 +19,7 @@ from .documents import ( read_payload_tree, ) from .leaves import SlotCodec -from .stores import JsonLinesBackingStore, SchemaConflictError, SingleFileMessagePackBackingStore, StoreUnreadableError +from .stores import JsonLinesBackingStore, SchemaConflictError, SingleFileMessagePackBackingStore, StoreLinkedError, StoreUnreadableError __all__ = [ "BackingStore", @@ -38,6 +38,7 @@ __all__ = [ "SchemaConflictError", "SingleFileMessagePackBackingStore", "SlotCodec", + "StoreLinkedError", "StoreUnreadableError", "UnionArm", "UnreadSlots", diff --git a/packages/cultcache-py/src/cultcache_py/stores.py b/packages/cultcache-py/src/cultcache_py/stores.py index 5b9ee44e..ba4c703d 100644 --- a/packages/cultcache-py/src/cultcache_py/stores.py +++ b/packages/cultcache-py/src/cultcache_py/stores.py @@ -101,22 +101,24 @@ class JsonLinesBackingStore: return envelopes def push(self, envelope: CultCacheEnvelope) -> None: - with self._lock: - existing = {(item.type, item.key): item for item in self.pull_all()} - existing[(envelope.type, envelope.key)] = envelope - self._replace_all(list(existing.values())) + self.push_all([envelope]) def push_all(self, envelopes: list[CultCacheEnvelope]) -> None: - with self._lock: - existing = {(item.type, item.key): item for item in self.pull_all()} + def merge(existing: dict[tuple[str, str], CultCacheEnvelope]) -> None: for envelope in envelopes: existing[(envelope.type, envelope.key)] = envelope - self._replace_all(list(existing.values())) + self._rewrite(merge) def delete(self, type: str, key: str) -> None: + self._rewrite(lambda existing: existing.pop((type, key), None)) + + def _rewrite(self, change: Callable[[dict[tuple[str, str], CultCacheEnvelope]], object]) -> None: + """The one entry of every write: refuses a link at the path, then reads the file, applies the change and replaces it.""" with self._lock: - existing = [item for item in self.pull_all() if not (item.type == type and item.key == key)] - self._replace_all(existing) + _refuse_linked(self.path) + existing = {(item.type, item.key): item for item in self.pull_all()} + change(existing) + self._replace_all(list(existing.values())) def _replace_all(self, envelopes: list[CultCacheEnvelope]) -> None: self.path.parent.mkdir(parents=True, exist_ok=True) @@ -363,7 +365,8 @@ class ReplaceKernel: def _replace_store(staged: str | os.PathLike[str], destination: str | os.PathLike[str]) -> None: """Replaces `destination` with `staged`: POSIX-semantics rename first on Windows (so a reader holding the store open - never sees it absent), os.replace elsewhere.""" + keeps it and rarely sees it absent; a stalled rename can still leave it absent for 5-47 ms on NTFS, + variants:ruling:posix-rename-absent-window-document), os.replace elsewhere.""" if sys.platform == "win32": replace_store_windows(os.fspath(staged), os.fspath(destination), WindowsReplaceKernel()) else: @@ -440,11 +443,33 @@ class WindowsReplaceKernel(ReplaceKernel): return 0 if self._k.MoveFileExW(staged, destination, flags) else self._ctypes.get_last_error() +class StoreLinkedError(OSError): + """A write was asked of a store path that is a link: a replace there would swap the link for a regular file.""" + + def __init__(self, path: Path) -> None: + super().__init__(f"CultCache store {path} is a link, and a write at it would replace the link; configure the link's target as the store path.") + self.path = path + + +def _refuse_linked(path: Path) -> None: + """Refuses a link at the store path: a file or directory link, and on Windows a junction or any reparse point (islink misses + junctions before Python 3.12). Absent is not a link.""" + linked = os.path.islink(path) + if not linked and sys.platform == "win32": + try: + linked = bool(os.lstat(path).st_file_attributes & 0x400) # FILE_ATTRIBUTE_REPARSE_POINT + except FileNotFoundError: + pass + if linked: + raise StoreLinkedError(path) + + @contextmanager def store_write_lock(path: Path, timeout: float = LOCK_TIMEOUT_SECONDS) -> Iterator[None]: """Holds the exclusive store lock on `.lock`, the lock the C#, Rust and TypeScript runtimes take: flock on Unix, LockFileEx over the whole file on Windows, polled every 10 ms with no handle held between polls. The lock file is created if absent and never written or deleted.""" + _refuse_linked(path) lock_path = path.with_name(path.name + ".lock") deadline = time.monotonic() + timeout while True: diff --git a/packages/cultcache-py/tests/test_cultcache.py b/packages/cultcache-py/tests/test_cultcache.py index a8c1dbe7..f83cfa96 100644 --- a/packages/cultcache-py/tests/test_cultcache.py +++ b/packages/cultcache-py/tests/test_cultcache.py @@ -12,6 +12,7 @@ from cultcache_py import ( CultCache, JsonLinesBackingStore, SingleFileMessagePackBackingStore, + StoreLinkedError, StoreUnreadableError, define_database_entry_type, define_document_type, @@ -1431,10 +1432,10 @@ class CultCacheTests(unittest.TestCase): self.assertNotIn("SECRET", str(caught.exception)) self.assertIn("not a valid store", str(caught.exception)) - # What a write does through a symbolic link today. R3 decides whether a write resolves - # the link or refuses it; this pins the current behaviour so that change is deliberate. + # A write at a store path that is a link is refused: a replace there would swap the link for a regular file and + # detach the store from its target. Reads through a live link keep working. @unittest.skipIf(os.name != "posix", "symbolic links need POSIX here") - def test_r3_decides_resolve_or_refuse_a_push_through_a_live_link_replaces_the_link(self) -> None: + def test_a_write_at_a_live_link_is_refused_and_nothing_changes_while_a_read_through_it_works(self) -> None: with tempfile.TemporaryDirectory() as tmp: volume = Path(tmp) / "volume" volume.mkdir() @@ -1442,12 +1443,65 @@ class CultCacheTests(unittest.TestCase): envelope = CultCacheEnvelope(key="a", type="t", payload=b"\x90", stored_at="2026-09-30T00:00:00Z") SingleFileMessagePackBackingStore(target).push(envelope) before = target.read_bytes() - link = Path(tmp) / "store.cc" + volume_before = sorted(p.name for p in volume.iterdir()) + link = Path(tmp) / "link.cc" link.symlink_to(target) - SingleFileMessagePackBackingStore(link).push(replace(envelope, key="b")) - self.assertFalse(link.is_symlink()) - self.assertTrue(link.is_file()) + store = SingleFileMessagePackBackingStore(link) + for write in ( + lambda: store.push(replace(envelope, key="b")), + lambda: store.push_all([replace(envelope, key="b")]), + lambda: store.delete("t", "a"), + ): + with self.assertRaises(StoreLinkedError) as caught: + write() + self.assertIn(str(link), str(caught.exception)) + self.assertTrue(link.is_symlink()) self.assertEqual(target.read_bytes(), before) + self.assertEqual(sorted(p.name for p in Path(tmp).iterdir()), ["link.cc", "volume"]) + self.assertEqual(sorted(p.name for p in volume.iterdir()), volume_before) + self.assertEqual([item.key for item in store.pull_all()], ["a"]) + + @unittest.skipIf(os.name != "posix", "symbolic links need POSIX here") + def test_a_json_lines_write_at_a_live_link_is_refused_and_the_link_survives(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + volume = Path(tmp) / "volume" + volume.mkdir() + target = volume / "store.jsonl" + envelope = CultCacheEnvelope(key="a", type="t", payload=b"\x90", stored_at="2026-09-30T00:00:00Z") + JsonLinesBackingStore(target).push(envelope) + before = target.read_bytes() + link = Path(tmp) / "link.jsonl" + link.symlink_to(target) + store = JsonLinesBackingStore(link) + for write in ( + lambda: store.push(replace(envelope, key="b")), + lambda: store.push_all([replace(envelope, key="b")]), + lambda: store.delete("t", "a"), + ): + with self.assertRaises(StoreLinkedError): + write() + self.assertTrue(link.is_symlink()) + self.assertEqual(target.read_bytes(), before) + self.assertEqual(sorted(p.name for p in Path(tmp).iterdir()), ["link.jsonl", "volume"]) + self.assertEqual(sorted(p.name for p in volume.iterdir()), ["store.jsonl"]) + + def test_store_linked_error_is_an_os_error_carrying_the_path(self) -> None: + error = StoreLinkedError(Path("some/store.cc")) + self.assertIsInstance(error, OSError) + self.assertEqual(error.path, Path("some/store.cc")) + + @unittest.skipIf(os.name != "posix", "symbolic links need POSIX here") + def test_a_write_at_a_live_directory_link_is_refused_as_a_link(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + real = Path(tmp) / "real" + real.mkdir() + link = Path(tmp) / "dirlink" + link.symlink_to(real, target_is_directory=True) + with self.assertRaises(StoreLinkedError): + SingleFileMessagePackBackingStore(link).push( + CultCacheEnvelope(key="a", type="t", payload=b"\x90", stored_at="2026-09-30T00:00:00Z")) + self.assertTrue(link.is_symlink()) + self.assertEqual(list(real.iterdir()), []) @unittest.skipIf(os.name != "posix", "symbolic links need POSIX here") def test_a_dangling_symlink_at_a_store_path_is_an_os_error_and_nothing_is_written(self) -> None: @@ -1460,7 +1514,7 @@ class CultCacheTests(unittest.TestCase): store = store_type(path) with self.assertRaises(FileNotFoundError): store.pull_all() - with self.assertRaises(FileNotFoundError): + with self.assertRaises(StoreLinkedError): store.push(CultCacheEnvelope(key="k", type="t", payload=b"\x90", stored_at="2026-09-30T00:00:00Z")) self.assertTrue(path.is_symlink()) self.assertEqual(list(volume.iterdir()), []) diff --git a/packages/cultcache-rs/CHANGELOG.md b/packages/cultcache-rs/CHANGELOG.md index 7f3caa73..b76a30d5 100644 --- a/packages/cultcache-rs/CHANGELOG.md +++ b/packages/cultcache-rs/CHANGELOG.md @@ -10,6 +10,7 @@ only in git history. Semver: this is a breaking change in the 0.x line (0.3.x to 0.4.0). +- A write at a store path that is a symbolic link, live or dangling, is refused with the new public `StoreLinkedError` (`error.downcast_ref::()`) before the lock and the replace, and writes nothing: `push`, `push_all`, `delete`, the exchanges, and a soft `push_all`, which used to skip a link and now refuses. Before, a write at a live link replaced the link with a regular file and a dangling link failed with `io::ErrorKind::NotFound`. The redb stores (`RedbMessagePackBackingStore`, `OwnedRedbMessagePackBackingStore`) are exempt: they are ruled migrated and deleted (`paged-store-redb-migrate-and-delete`) and still open a live link's target. Configure the link's target as the store path. Reads through a live link work. - A record whose payload carries unread slots is read-only. A payload with a slot past its type's declared slots, at any depth (record, nested record, list element, union arm, unit arm), still decodes, and every write or removal of its key is now refused before any store is touched: `put`, `put_envelope`, `put_raw_envelope`, `put_prepared_batch` (the whole batch) and `delete`, so `update` too. The refusal is `SchemaConflictError` with the new `unread_slots`; its message names the type and both slot counts and never a slot value. Holders of such a record used to overwrite it and drop the extra slots silently. The payload nesting refusal still runs first. A held payload this build cannot read at all (an arm it lacks from a newer writer, or a corrupt record) is read-only too: every write or removal is refused with an error naming the type and key (a newer writer's record is read with that build; a corrupt one is restored from a backup or removed at the store itself, as no cache call removes it), and `is_writable` answers false. `load_soa` is a write of every held key it omits or changes, so it refuses to drop or replace a read-only record. `is_writable` also answers false for a key a store carries as a foreign record and for a type other than the one holding the key, as the writes do. Each write over a held key decodes the held payload once to ask (about 40% of a `put` of a 200,000-element record, measured in release); keys nothing holds pay nothing. - cultmesh-rs `pull_rudp_catalog_snapshot` applies a snapshot as one cache commit (`put_prepared_batch`), as C# does: a snapshot document for a key the node holds read-only, or any other refusal, applies nothing. It used to apply document by document, so a refusal ended the pull with the earlier documents kept. A snapshot whose types route to different backing stores is now refused whole, as a batch cannot span stores. C# `CultCache.Commit` refuses a cross-store commit the same way (`The batch spans A and B; a commit lands in one home store`), so the two agree; a test pins it. - `SchemaConflictError` gains the public field `unread_slots: Option`; code that builds it with a struct literal must set it (`None`). diff --git a/packages/cultcache-rs/README.md b/packages/cultcache-rs/README.md index 2d217083..06c530b1 100644 --- a/packages/cultcache-rs/README.md +++ b/packages/cultcache-rs/README.md @@ -384,9 +384,10 @@ an empty store. Any other failure to reach it, such as a symlink loop, a file where a parent directory should be, or a missing permission, is an I/O error. A store file that exists and holds zero bytes is `Undecodable`: no CultCache writer leaves one. A symbolic link whose target is gone is not a missing store: -reading it is an I/O error, so a read-then-write (`push`, `push_all`, `delete`, the exchanges) -fails before writing. Writes do not yet follow links: a write through a live link -replaces the link with a regular file and leaves its target alone. A refusal names no +reading it is an I/O error. A write at a store path that is a link, live or dangling, is +refused with `StoreLinkedError` (`error.downcast_ref::()`) before the +lock and the replace, so no write swaps a link for a regular file; configure the link's +target as the store path. Reads through a live link work. A refusal names no value read from the file; it names a record only by its key and schema id, and a header only when it has the shape `cultcache.store.v`. diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index b3d6a441..e3e47e43 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -1176,6 +1176,35 @@ impl std::fmt::Display for CultCacheStoreUnreadable { impl std::error::Error for CultCacheStoreUnreadable {} +/// A write was asked of a store path that is a link (a file or directory symbolic link, or a Windows +/// junction): a replace there would swap the link for a regular file and detach the store from its +/// target. Nothing was locked or written. Configure the link's target as the store path. Reads through +/// a live link are unaffected. Find it with `error.downcast_ref::()`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StoreLinkedError { + pub path: PathBuf, +} + +impl std::fmt::Display for StoreLinkedError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + formatter, + "CultCache store {} is a link, and a write at it would replace the link; configure the link's target as the store path", + self.path.display() + ) + } +} + +impl std::error::Error for StoreLinkedError {} + +/// Refuses a write at a link. A path that cannot be inspected is left to the open that follows. +fn refuse_linked(path: &Path) -> Result<()> { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() => Err(StoreLinkedError { path: path.to_path_buf() }.into()), + _ => Ok(()), + } +} + /// A write to a single-file store that failed, and what it left on disk. It sits in the /// error chain of every such failure, above the cause, so a consumer tells the outcomes /// apart with `error.downcast_ref::()`. @@ -1719,6 +1748,7 @@ impl SingleFileMessagePackBackingStore { } fn with_exclusive_lock(&self, action: impl FnOnce() -> Result) -> Result { + refuse_linked(&self.path)?; let lock = self.open_lock_file()?; fs2::FileExt::lock_exclusive(&lock) .with_context(|| format!("failed to lock {}", self.lock_path().display()))?; @@ -1830,6 +1860,7 @@ impl SingleFileMessagePackBackingStore { } fn try_with_exclusive_lock(&self, action: impl FnOnce() -> Result) -> Result> { + refuse_linked(&self.path)?; let lock = self.open_lock_file()?; match fs2::FileExt::try_lock_exclusive(&lock) { Ok(()) => {} @@ -5374,12 +5405,6 @@ mod tests { Ok(()) } - fn io_not_found(error: &anyhow::Error) -> bool { - error - .chain() - .any(|cause| cause.downcast_ref::().is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)) - } - fn test_envelope(r#type: &str, key: &str, payload: &[u8]) -> CultCacheEnvelope { CultCacheEnvelope { key: key.to_string(), @@ -7314,7 +7339,14 @@ mod tests { let temp = tempfile::tempdir()?; let path = temp.path().join("store.cc"); std::os::unix::fs::symlink(&path, &path)?; - assert_io_failure_on_every_read(&SingleFileMessagePackBackingStore::new(&path)); + let [pull, snapshot, exchange] = every_read(&SingleFileMessagePackBackingStore::new(&path)); + for error in [pull.unwrap_err(), snapshot.unwrap_err()] { + let io = io_error_in(&error).expect("the failure to reach the store is an io::Error"); + assert_ne!(io.kind(), std::io::ErrorKind::NotFound, "{error:#}"); + assert_eq!(unreadable_kind(&error), None, "{error:#}"); + } + // The exchange is a write: a link at the path is refused before it reads. + assert!(exchange.unwrap_err().downcast_ref::().is_some()); assert!(fs::symlink_metadata(&path)?.file_type().is_symlink()); Ok(()) } @@ -7753,12 +7785,13 @@ mod tests { let path = temp.path().join("store.cc"); std::os::unix::fs::symlink(&target, &path)?; let store = SingleFileMessagePackBackingStore::new(&path); - for result in every_read(&store) { - let error = result.expect_err("a dangling link must not read as an empty store"); + let [pull, snapshot, exchange] = every_read(&store); + for error in [pull.unwrap_err(), snapshot.unwrap_err()] { assert!(io_error_in(&error).is_some(), "{error:#}"); assert_eq!(unreadable_kind(&error), None, "{error:#}"); } - // Every merging writer refuses on its own read, so none can replace the link. + assert!(exchange.unwrap_err().downcast_ref::().is_some()); + // Every writer refuses the link before its lock, so none can replace it. let mut writer = SingleFileMessagePackBackingStore::new(&path); let delta = snapshot_envelope("delta", b"4"); let writes = [ @@ -7770,7 +7803,7 @@ mod tests { ]; for (index, result) in writes.into_iter().enumerate() { let error = result.expect_err("a merging write through a dangling link must refuse"); - assert!(io_error_in(&error).is_some(), "write {index}: {error:#}"); + assert!(error.downcast_ref::().is_some(), "write {index}: {error:#}"); assert_eq!(write_failure_kind(&error), None, "write {index}: {error:#}"); } assert!(fs::symlink_metadata(&path)?.file_type().is_symlink()); @@ -7909,27 +7942,65 @@ mod tests { Ok(()) } - // What a write does through a symbolic link today. R3 decides whether a write - // resolves the link or refuses it; until then these pin the current behaviour so the - // change is deliberate. Reads refuse a dangling link (see the test above). + // A write at a store path that is a link is refused: a replace there would swap the link for a regular file and + // detach the store from its target. Reads through a live link keep working. #[cfg(unix)] #[test] - fn r3_decides_resolve_or_refuse_a_push_through_a_live_link_replaces_the_link() -> Result<()> { + fn a_write_at_a_live_link_is_refused_and_nothing_changes_while_a_read_through_it_works() -> Result<()> { let temp = tempfile::tempdir()?; let volume = temp.path().join("volume"); fs::create_dir(&volume)?; let target = volume.join("store.cc"); SingleFileMessagePackBackingStore::new(&target).push(&snapshot_envelope("a", b"1"))?; let before = fs::read(&target)?; - let link = temp.path().join("store.cc"); + let volume_before = dir_names(&volume)?; + let link = temp.path().join("link.cc"); std::os::unix::fs::symlink(&target, &link)?; - SingleFileMessagePackBackingStore::new(&link).push(&snapshot_envelope("b", b"2"))?; - assert!(fs::symlink_metadata(&link)?.file_type().is_file(), "the link was replaced by a regular file"); - assert_eq!(fs::read(&target)?, before, "the store the link pointed to is unchanged"); + let mut store = SingleFileMessagePackBackingStore::new(&link); + let errors = [ + store.push(&snapshot_envelope("b", b"2")).unwrap_err(), + store.push_all(&[snapshot_envelope("b", b"2")], PushAllOptions::default()).unwrap_err(), + store.push_all(&[snapshot_envelope("b", b"2")], PushAllOptions { soft: true }).unwrap_err(), + store.delete(&snapshot_envelope("a", b"1")).unwrap_err(), + store.compare_exchange_snapshot(&[], &[snapshot_envelope("b", b"2")]).unwrap_err(), + store.try_compare_exchange_snapshot(&[], &[snapshot_envelope("b", b"2")]).err().expect("refused"), + ]; + for error in &errors { + let linked = error.downcast_ref::().unwrap_or_else(|| panic!("{error:#}")); + assert_eq!(linked.path, link); + assert!(error.to_string().contains(&link.display().to_string()), "{error:#}"); + } + assert!(fs::symlink_metadata(&link)?.file_type().is_symlink(), "the link is left as it was"); + assert_eq!(fs::read(&target)?, before); + assert_eq!(dir_names(&volume)?, volume_before, "no lock or staging file beside the target"); + assert_eq!(dir_names(temp.path())?, ["link.cc", "volume"], "no lock or staging file beside the link"); + assert_eq!(store.pull_all()?, vec![snapshot_envelope("a", b"1")]); Ok(()) } + #[cfg(unix)] + #[test] + fn a_write_at_a_live_directory_link_is_refused_as_a_link() -> Result<()> { + let temp = tempfile::tempdir()?; + let real = temp.path().join("real"); + fs::create_dir(&real)?; + let link = temp.path().join("dirlink"); + std::os::unix::fs::symlink(&real, &link)?; + let error = SingleFileMessagePackBackingStore::new(&link).push(&snapshot_envelope("a", b"1")).unwrap_err(); + assert!(error.downcast_ref::().is_some(), "{error:#}"); + assert!(fs::symlink_metadata(&link)?.file_type().is_symlink()); + assert_eq!(fs::read_dir(&real)?.count(), 0); + Ok(()) + } + + #[cfg(unix)] + fn dir_names(dir: &Path) -> Result> { + let mut names = fs::read_dir(dir)?.map(|entry| Ok(entry?.file_name().to_string_lossy().into_owned())).collect::>>()?; + names.sort(); + Ok(names) + } + // The two cases the vectors cannot hold: nothing at the path is written, and a dangling link is refused and left as it is. #[test] fn a_soft_push_all_writes_where_nothing_is_and_refuses_a_dangling_link() -> Result<()> { @@ -7951,7 +8022,7 @@ mod tests { let error = SingleFileMessagePackBackingStore::new(&link) .push_all(&[snapshot_envelope("a", b"1")], PushAllOptions { soft: true }) .unwrap_err(); - assert!(io_not_found(&error), "{error:#}"); + assert!(error.downcast_ref::().is_some(), "{error:#}"); assert!(fs::symlink_metadata(&link)?.file_type().is_symlink(), "the link was replaced"); assert_eq!(fs::read_dir(&volume)?.count(), 0, "nothing reached the link's volume"); } diff --git a/packages/cultcache-ts/CHANGELOG.md b/packages/cultcache-ts/CHANGELOG.md index fea670db..c1a91df6 100644 --- a/packages/cultcache-ts/CHANGELOG.md +++ b/packages/cultcache-ts/CHANGELOG.md @@ -7,7 +7,7 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following ### Breaking -- `SingleFileMessagePackBackingStore.pushAll` through a dangling symbolic link at the store path is refused with `ENOENT`, as `push` and `pullAll` are, and writes nothing; it used to replace the link with a new store. The reader owns the verdict on what is at the path. +- A write at a single-file store path that is a symbolic link, live or dangling, is refused with the new `StoreLinkedError` (exported from the package entry; `error.path` names the path) before the lock and the replace, and writes nothing: `push`, `pushAll`, `delete` and a soft flush alike. Before, a write at a live link replaced the link with a regular file, a dangling link failed with `ENOENT` (or, for `pushAll`, was replaced), and a soft flush at a link skipped. A caller catching `ENOENT` around a write at a dangling link now catches `StoreLinkedError`; configure the link's target as the store path. Reads through a live link work. An `lstat` failure other than `ENOENT` (`ENOTDIR`, `EACCES`, `ELOOP`) is rethrown as it is. - `SingleFileMessagePackBackingStore.pushAll` writes the given entries onto the store as it is under the lock, as `push` does: a record under the same key is replaced and no other is removed. It used to replace the whole file with the entries, so `CultMesh.flush()` from a cache that held less than the store (a node created with `pullOnStart: false`, or one whose image was stale) wiped the records it never read. A caller that relied on `pushAll` to reset a store removes the file first. A soft flush is unchanged. `CultCache.storedSnapshot()` is the image as the stores hold it (a global loaded from a legacy key under that key) and is what `CultMesh.flush` persists, so a flush no longer leaves such a global under two keys; `snapshot()` is unchanged. - A single-file store marked `cultcache.store.v3` (it holds an element id, written by the C# runtime) is read, and a rewrite keeps the mark and the ids; this runtime does not decide when a document carries ids. A runtime from before this change refuses a v3 store by name, so rolling back to an older release after a store has been written as v3 is one-way: there is no carry-back, so restore from a copy taken before upgrading. - `indexes` are unique, as C# `[CultIndex]`: a write or a load that leaves two records of a type holding one non-blank diff --git a/packages/cultcache-ts/src/index.ts b/packages/cultcache-ts/src/index.ts index f55dd049..8d726e3d 100644 --- a/packages/cultcache-ts/src/index.ts +++ b/packages/cultcache-ts/src/index.ts @@ -1,2 +1,3 @@ export * from "./browser"; export { SingleFileMessagePackBackingStore, readSingleFileStore, type SingleFileStoreRead } from "./single-file-messagepack-backing-store"; +export { StoreLinkedError } from "./store-file"; diff --git a/packages/cultcache-ts/src/store-file.ts b/packages/cultcache-ts/src/store-file.ts index 4c7015cc..e6988d23 100644 --- a/packages/cultcache-ts/src/store-file.ts +++ b/packages/cultcache-ts/src/store-file.ts @@ -5,7 +5,7 @@ // lazily inside `loadKernel`, so a consumer that never writes a store never loads it. import { closeSync, openSync, constants as fsConstants } from "node:fs"; -import { mkdir, open, rename, rm } from "node:fs/promises"; +import { lstat, mkdir, open, rename, rm } from "node:fs/promises"; import type { FileHandle } from "node:fs/promises"; import { dirname } from "node:path"; @@ -25,6 +25,30 @@ export class StoreReplacedNotDurableError extends Error { } } +/** Thrown when a write is asked of a store path that is a link: a replace there would swap the link for a regular file. */ +export class StoreLinkedError extends Error { + constructor(readonly path: string) { + super(`CultCache store ${path} is a link, and a write at it would replace the link; configure the link's target as the store path.`); + this.name = "StoreLinkedError"; + } +} + +/** Refuses a link at the store path (file or directory link; libuv reports Windows junctions as links). Absent is not a link. */ +export async function refuseLinkedStore(storePath: string): Promise { + let linked = false; + try { + linked = (await lstat(storePath)).isSymbolicLink(); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } + + if (linked) { + throw new StoreLinkedError(storePath); + } +} + /** The file operations of one commit. Tests record or fail them; production uses `nodeStoreFileOps`. */ export interface StoreFileOps { createExclusive(path: string): Promise; @@ -177,6 +201,7 @@ export async function withStoreWriteLock( ): Promise { const lockPath = `${storePath}.lock`; const timeoutMs = options.timeoutMs ?? LOCK_TIMEOUT_MS; + await refuseLinkedStore(storePath); await mkdir(dirname(lockPath), { recursive: true }); const started = Date.now(); diff --git a/packages/cultcache-ts/test/cult-cache.test.ts b/packages/cultcache-ts/test/cult-cache.test.ts index 7c559af7..0ea29c62 100644 --- a/packages/cultcache-ts/test/cult-cache.test.ts +++ b/packages/cultcache-ts/test/cult-cache.test.ts @@ -15,6 +15,7 @@ import { CultCache } from "../src/cult-cache"; import { inspectCultCacheBytes } from "../src/cult-cache-inspector"; import { defineDocumentRegistry, defineDocumentType, documentPayloadBytes, documentPayloadFromBytes } from "../src/document"; import { SingleFileMessagePackBackingStore, readSingleFileStore, readStoreBytes } from "../src/single-file-messagepack-backing-store"; +import { StoreLinkedError, withStoreWriteLock } from "../src/store-file"; import { SchemaConflictError, StoreUnreadableError, refuseUnreadStoreBytes } from "../src/store-format"; import type { AnyCultCacheDocumentDefinition, CacheBackingStore, CultCacheEnvelope, CultCacheSchema } from "../src/types"; @@ -2466,8 +2467,8 @@ test("SingleFileMessagePackBackingStore refuses a dangling symlink at its path a () => store.pullAll(), (error: NodeJS.ErrnoException) => error.code === "ENOENT" && error.message.includes("symbolic link") && error.message.includes(file), ); - await assert.rejects(() => store.push(envelope), (error: NodeJS.ErrnoException) => error.code === "ENOENT"); - await assert.rejects(() => store.pushAll([envelope], { soft: true }), (error: NodeJS.ErrnoException) => error.code === "ENOENT"); + await assert.rejects(() => store.push(envelope), (error: unknown) => error instanceof StoreLinkedError); + await assert.rejects(() => store.pushAll([envelope], { soft: true }), (error: unknown) => error instanceof StoreLinkedError); assert.ok((await lstat(file)).isSymbolicLink()); assert.deepEqual(await readdir(volume), []); }); @@ -2508,9 +2509,9 @@ test("SingleFileMessagePackBackingStore record refusal names only a string key", } }); -// What a write does through a symbolic link today. R3 decides whether a write resolves the link -// or refuses it; these pin the current behaviour so that change is deliberate. -test("R3 decides resolve-or-refuse: a push through a live link replaces the link", { skip: process.platform === "win32" }, async () => { +// A write at a store path that is a link is refused: a replace there would swap the link for a regular file and +// detach the store from its target. Reads through a live link keep working. +test("a write at a live link is refused and nothing changes, while a read through it still works", { skip: process.platform === "win32" }, async () => { const dir = await mkdtemp(join(tmpdir(), "cultcache-live-link-")); const volume = join(dir, "volume"); await mkdir(volume); @@ -2518,11 +2519,31 @@ test("R3 decides resolve-or-refuse: a push through a live link replaces the link const envelope = { key: "a", type: "t", payload: documentPayloadFromBytes(Uint8Array.of(0x90)), storedAt: "2026-09-30T00:00:00Z" }; await new SingleFileMessagePackBackingStore(target).push(envelope); const before = await readFile(target); - const link = join(dir, "store.cc"); + const volumeBefore = (await readdir(volume)).sort(); + const link = join(dir, "link.cc"); await symlink(target, link); - await new SingleFileMessagePackBackingStore(link).push({ ...envelope, key: "b" }); - assert.ok((await lstat(link)).isFile()); + const store = new SingleFileMessagePackBackingStore(link); + const refused = (error: unknown) => error instanceof StoreLinkedError && error.path === link && error.message.includes(link); + await assert.rejects(() => store.push({ ...envelope, key: "b" }), refused); + await assert.rejects(() => store.pushAll([{ ...envelope, key: "b" }]), refused); + await assert.rejects(() => store.delete(envelope), refused); + assert.ok((await lstat(link)).isSymbolicLink()); assert.deepEqual(await readFile(target), before); + assert.deepEqual((await readdir(dir)).sort(), ["link.cc", "volume"]); + assert.deepEqual((await readdir(volume)).sort(), volumeBefore); + assert.deepEqual((await store.pullAll()).map((e) => e.key), ["a"]); +}); + +test("a write at a live directory link is refused as a link", { skip: process.platform === "win32" }, async () => { + const dir = await mkdtemp(join(tmpdir(), "cultcache-dir-link-")); + const realDir = join(dir, "real"); + await mkdir(realDir); + const link = join(dir, "dirlink"); + await symlink(realDir, link); + const envelope = { key: "a", type: "t", payload: documentPayloadFromBytes(Uint8Array.of(0x90)), storedAt: "2026-09-30T00:00:00Z" }; + await assert.rejects(() => new SingleFileMessagePackBackingStore(link).push(envelope), (error: unknown) => error instanceof StoreLinkedError); + assert.ok((await lstat(link)).isSymbolicLink()); + assert.deepEqual(await readdir(realDir), []); }); test("a pushAll through a dangling link is refused like push, and the link and its volume are left as they were", { skip: process.platform === "win32" }, async () => { @@ -2532,7 +2553,7 @@ test("a pushAll through a dangling link is refused like push, and the link and i const link = join(dir, "store.cc"); await symlink(join(volume, "store.cc"), link); const envelope = { key: "a", type: "t", payload: documentPayloadFromBytes(Uint8Array.of(0x90)), storedAt: "2026-09-30T00:00:00Z" }; - await assert.rejects(() => new SingleFileMessagePackBackingStore(link).pushAll([envelope]), (error: NodeJS.ErrnoException) => error.code === "ENOENT"); + await assert.rejects(() => new SingleFileMessagePackBackingStore(link).pushAll([envelope]), (error: unknown) => error instanceof StoreLinkedError); assert.ok((await lstat(link)).isSymbolicLink()); assert.deepEqual(await readdir(volume), []); }); @@ -2827,7 +2848,6 @@ test("every registration refusal is a SchemaConflictError naming the id and both // Cross-runtime store lock: each runtime's writer excludes the others on `.lock`. import { spawn } from "node:child_process"; -import { withStoreWriteLock } from "../src/store-file"; type LockHolder = { exited: Promise; stderr: () => string }; diff --git a/packages/cultcache-ts/test/store-file.test.ts b/packages/cultcache-ts/test/store-file.test.ts index 39436e30..485df058 100644 --- a/packages/cultcache-ts/test/store-file.test.ts +++ b/packages/cultcache-ts/test/store-file.test.ts @@ -10,6 +10,7 @@ import { promisify } from "node:util"; import { decode, encode } from "@msgpack/msgpack"; +import { StoreLinkedError as PackageStoreLinkedError } from "../src/index"; import { documentPayloadBytes, documentPayloadFromBytes } from "../src/document"; import { SingleFileMessagePackBackingStore } from "../src/single-file-messagepack-backing-store"; import { SchemaConflictError, StoreUnreadableError } from "../src/store-format"; @@ -23,6 +24,7 @@ import { replaceStoreFile, replaceStoreFileWindows, windowsKernel, + refuseLinkedStore, withStoreWriteLock, } from "../src/store-file"; import type { ReplaceKernel, StoreFileOps } from "../src/store-file"; @@ -818,3 +820,35 @@ test("opening writes nothing: a missing store leaves no lock file, a legacy stor await rm(directory, { recursive: true, force: true }); } }); + +test("StoreLinkedError is importable from the package entry and is what a write at a link throws", { skip: process.platform === "win32" }, async () => { + const directory = await scratch(); + try { + const target = join(directory, "target.cc"); + await writeFile(target, "x"); + const link = join(directory, "link.cc"); + await fsp.symlink(target, link); + await assert.rejects( + () => refuseLinkedStore(link), + (error: unknown) => error instanceof PackageStoreLinkedError && error.path === link && error.name === "StoreLinkedError", + ); + await assert.rejects(() => new SingleFileMessagePackBackingStore(link).pushAll([]), (error: unknown) => error instanceof PackageStoreLinkedError); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("refuseLinkedStore lets a path that is absent pass and rethrows any other lstat failure", { skip: process.platform === "win32" }, async () => { + const directory = await scratch(); + try { + await refuseLinkedStore(join(directory, "absent.cc")); + const file = join(directory, "file"); + await writeFile(file, "x"); + await assert.rejects( + () => refuseLinkedStore(join(file, "child.cc")), + (error: unknown) => (error as NodeJS.ErrnoException).code === "ENOTDIR", + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); From 73e2131d9aa80a48908059bd2d247dddeaee293f Mon Sep 17 00:00:00 2001 From: Libby Date: Fri, 13 Nov 2026 00:36:20 +0000 Subject: [PATCH 69/93] C#: a write through a live link at the store path is refused; read-under-lock pinned (store-link-cs) (#151) --- .../DirectoryMessagePackBackingStore.cs | 24 +- src/GameCult.Caching/CultCache.cs | 33 +- .../CultStoreLinkedException.cs | 24 ++ src/GameCult.Caching/CultStoreLinks.cs | 22 ++ .../StoreLinkRefusalTests.cs | 330 ++++++++++++++++++ .../StoreReadabilityTests.cs | 59 +++- .../StoreWriteReadsUnderTheLockTests.cs | 132 +++++++ .../CultMeshSingleFileStoreTests.cs | 86 +++-- 8 files changed, 676 insertions(+), 34 deletions(-) create mode 100644 src/GameCult.Caching/CultStoreLinkedException.cs create mode 100644 src/GameCult.Caching/CultStoreLinks.cs create mode 100644 tests/GameCult.Caching.Tests/StoreLinkRefusalTests.cs create mode 100644 tests/GameCult.Caching.Tests/StoreWriteReadsUnderTheLockTests.cs diff --git a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs index 8f76cbae..f916a13b 100644 --- a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs +++ b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs @@ -710,17 +710,23 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore // Only a writer creates the records folder and the lock, so opening the store creates nothing. A reader opens an // existing lock and returns null when there is none; that does not mean no writer committed (a store may be copied // without its lock, and a first writer may create it mid-load), so an unleased load checks that it settled. + // A writer's lease is also where a link at the manifest path is refused (a commit would replace the link with a file): before + // the records folder or the lock is made, and again once the lease is held, because a link can be put there while it waited. private FileStream? AcquireCommitLease(bool wait, bool create) { if (create) + { + CultStoreLinks.Refuse(_manifestFile.FullName); Directory.CreateDirectory(_recordDirectory.FullName); + } var lockPath = Path.Combine(_recordDirectory.FullName, ".commit.lock"); var started = Stopwatch.StartNew(); while (true) { + FileStream held; try { - return new FileStream( + held = new FileStream( lockPath, create ? FileMode.OpenOrCreate : FileMode.Open, FileAccess.ReadWrite, @@ -743,7 +749,23 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore catch (IOException) when (started.Elapsed < TimeSpan.FromSeconds(30)) { Thread.Sleep(10); + continue; } + + if (create) + { + try + { + CultStoreLinks.Refuse(_manifestFile.FullName); + } + catch + { + held.Dispose(); + throw; + } + } + + return held; } } diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index 48bad3a3..c461bc67 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -5132,7 +5132,7 @@ namespace GameCult.Caching var attributes = FileInfo.Attributes; if ((int)attributes == -1) return null; - if ((attributes & FileAttributes.Directory) != 0 && (attributes & FileAttributes.ReparsePoint) == 0) + if ((attributes & FileAttributes.Directory) != 0 && Directory.Exists(FileInfo.FullName)) throw new IOException("The store path is a directory, not a store file."); try { @@ -5154,25 +5154,27 @@ namespace GameCult.Caching } // Something is at the path even when the path cannot be opened: a link keeps the reparse-point attribute whether or not its - // target is there. A path with nothing at it reports no attributes at all (-1). - private bool PathIsALink() - { - FileInfo.Refresh(); - var attributes = FileInfo.Attributes; - return (int)attributes != -1 && (attributes & FileAttributes.ReparsePoint) != 0; - } + // target is there. + private bool PathIsALink() => CultStoreLinks.IsLink(FileInfo.FullName); + + // A write never goes through a link at the store path: replacing it would silently detach the store from its link. + private void RefuseLinkedStore() => CultStoreLinks.Refuse(FileInfo.FullName); // The lock is a sidecar opened exclusively, which excludes other handles in this process and in others alike. + // Every write takes the lock first, so the link is refused here twice: before the lock file or anything else is made, and + // again once the lock is held, because a link can be put at the path while this writer waited on another's lock. private FileStream? AcquireLock(bool wait) { + RefuseLinkedStore(); Directory.CreateDirectory(FileInfo.DirectoryName!); var lockPath = FileInfo.FullName + ".lock"; var started = Stopwatch.StartNew(); while (true) { + FileStream held; try { - return new FileStream(lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None, bufferSize: 1); + held = new FileStream(lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None, bufferSize: 1); } catch (IOException) when (!wait) { @@ -5181,7 +5183,20 @@ namespace GameCult.Caching catch (IOException) when (started.Elapsed < TimeSpan.FromSeconds(30)) { Thread.Sleep(10); + continue; } + + try + { + RefuseLinkedStore(); + } + catch + { + held.Dispose(); + throw; + } + + return held; } } diff --git a/src/GameCult.Caching/CultStoreLinkedException.cs b/src/GameCult.Caching/CultStoreLinkedException.cs new file mode 100644 index 00000000..c7272fe0 --- /dev/null +++ b/src/GameCult.Caching/CultStoreLinkedException.cs @@ -0,0 +1,24 @@ +using System.IO; + +namespace GameCult.Caching +{ + /// + /// A write was attempted at a store path that is a link (a symbolic link to a file or a directory, a Windows junction or any + /// other reparse-point link). The write is refused before the lock, the staging file or the replace, and again once the lock + /// is held, so a link that exists when a writer takes the lock leaves the link and its target untouched. A link created by + /// something that ignores the lock after that check, while the commit builds and writes the new image, is still replaced; + /// that window cannot be closed without native no-follow opens. Configure the link's target as the store path to write to + /// it; reads through a live link are unchanged. + /// + public sealed class CultStoreLinkedException : IOException + { + public CultStoreLinkedException(string path) + : base($"CultCache store {path} is a link, and a write at it would replace the link; configure the link's target as the store path.") + { + Path = path; + } + + /// The store path that is a link. + public string Path { get; } + } +} diff --git a/src/GameCult.Caching/CultStoreLinks.cs b/src/GameCult.Caching/CultStoreLinks.cs new file mode 100644 index 00000000..cc9ec2b0 --- /dev/null +++ b/src/GameCult.Caching/CultStoreLinks.cs @@ -0,0 +1,22 @@ +using System.IO; + +namespace GameCult.Caching +{ + // The one decision on whether a store-owned file is a link, shared by every store kind that replaces files. + internal static class CultStoreLinks + { + // A path with nothing at it reports no attributes at all (-1). Any reparse point counts as a link, whether or not its + // target is there. + internal static bool IsLink(string path) + { + var attributes = new FileInfo(path).Attributes; + return (int)attributes != -1 && (attributes & FileAttributes.ReparsePoint) != 0; + } + + internal static void Refuse(string path) + { + if (IsLink(path)) + throw new CultStoreLinkedException(path); + } + } +} diff --git a/tests/GameCult.Caching.Tests/StoreLinkRefusalTests.cs b/tests/GameCult.Caching.Tests/StoreLinkRefusalTests.cs new file mode 100644 index 00000000..4444b176 --- /dev/null +++ b/tests/GameCult.Caching.Tests/StoreLinkRefusalTests.cs @@ -0,0 +1,330 @@ +#nullable enable +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using GameCult.Caching.MessagePack; +using NUnit.Framework; +using static GameCult.Caching.Tests.ElementIdTests; +using static GameCult.Caching.Tests.StoreReadabilityTests; + +namespace GameCult.Caching.Tests +{ + // A write never replaces a link at a store-owned path. The refusal holds for every way a write reaches a store (waiting or + // not, as a judge of another store's commit), for a link put there while the write waited on the store's lock, and for the + // directory store's manifest. + public class StoreLinkRefusalTests + { + private static readonly CultDocumentRegistry Registry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck), typeof(VectorItem) }); + private string _directory = ""; + + [SetUp] + public void SetUp() + { + _directory = Path.Combine(Path.GetTempPath(), "cultlib-link-refusal-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_directory); + } + + [TearDown] + public void TearDown() + { + if (!Directory.Exists(_directory)) + return; + foreach (var entry in Directory.EnumerateFileSystemEntries(_directory).Where(IsALink)) + File.Delete(entry); + Directory.Delete(_directory, true); + } + + private string PathOf(string name) => Path.Combine(_directory, name); + + private static bool IsALink(string path) + { + var attributes = new FileInfo(path).Attributes; + return (int)attributes != -1 && (attributes & FileAttributes.ReparsePoint) != 0; + } + + private static void Link(string path, string target) + { + try + { + File.CreateSymbolicLink(path, target); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Assert.Ignore("A file symlink needs a privilege this account lacks: " + ex.Message); + } + } + + private static void SeedSingle(string path) + { + using var seed = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }); + seed.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "seed" }, new CultRecordKey("seed"))); + } + + private static void SeedItem(string path) + { + using var seed = new CultCache(Registry); + seed.AddBackingStore(new SingleFileMessagePackBackingStore(path), typeof(VectorItem)); + seed.Commit(batch => batch.Upsert(typeof(VectorItem), new VectorItem(), new CultRecordKey("item"))); + } + + // Runs `write` while another holder has `lockPath`; `swap` runs while it waits, then the lock is released. + private static Exception? WriteWhileWaiting(string lockPath, Action write, Action swap) + { + using var held = new FileStream(lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + var task = Task.Run(write); + Assert.That(task.Wait(TimeSpan.FromMilliseconds(600)), Is.False, "the lock is held, so the write must wait"); + swap(); + held.Dispose(); + try + { + Assert.That(task.Wait(TimeSpan.FromSeconds(10)), Is.True); + return null; + } + catch (AggregateException ex) + { + return ex.GetBaseException(); + } + } + + private static void ReplaceWithLink(string path, string target) + { + File.Delete(path); + Link(path, target); + } + + // ---- a link put at the store path while the write waited on the lock ---- + + [Test] + public void ACommitRefusesALinkPutAtTheStoreWhileItWaitedOnTheLock() + { + var path = PathOf("store.cc"); + var target = PathOf("shared.cc"); + SeedSingle(path); + SeedSingle(target); + var before = File.ReadAllBytes(target); + using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry, FlushOnDispose = false, StoreFlushOnDispose = false }); + + var error = WriteWhileWaiting(path + ".lock", + () => cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "late" }, new CultRecordKey("late"))), + () => ReplaceWithLink(path, target)); + + Assert.That(error, Is.InstanceOf(), error?.ToString()); + Assert.That(((CultStoreLinkedException)error!).Path, Is.EqualTo(path)); + Assert.That(IsALink(path), "the link was replaced"); + Assert.That(File.ReadAllBytes(target), Is.EqualTo(before)); + } + + [Test] + public void AFlushRefusesALinkPutAtTheStoreWhileItWaitedOnTheLock() + { + var path = PathOf("store.cc"); + var target = PathOf("shared.cc"); + SeedSingle(path); + SeedSingle(target); + var before = File.ReadAllBytes(target); + using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry, FlushOnDispose = false, StoreFlushOnDispose = false }); + cache.UpsertAsync(typeof(IdDeck), new IdDeck { Name = "late" }, new CultRecordKey("late")).GetAwaiter().GetResult(); + + var error = WriteWhileWaiting(path + ".lock", () => cache.FlushAllBackingStores(), () => ReplaceWithLink(path, target)); + + Assert.That(error, Is.InstanceOf(), error?.ToString()); + Assert.That(IsALink(path), "the link was replaced"); + Assert.That(File.ReadAllBytes(target), Is.EqualTo(before)); + } + + // ---- the non-waiting and judging write paths ---- + + [Test] + public void ATryCommitAtALiveLinkIsRefusedNotContendedOrReplaced() + { + var path = PathOf("store.cc"); + var link = PathOf("link.cc"); + SeedSingle(path); + Link(link, path); + var before = File.ReadAllBytes(path); + using var cache = CultCacheMessagePack.Create(link, new CultCacheOpenOptions { Registry = Registry, FlushOnDispose = false, StoreFlushOnDispose = false }); + + var refusal = Assert.Throws(() => + cache.TryCommit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "t" }, new CultRecordKey("t"))))!; + + Assert.That(refusal.Path, Is.EqualTo(link)); + Assert.That(refusal.Message, Does.Contain(link)); + Assert.That(IsALink(link), "the link was replaced"); + Assert.That(File.ReadAllBytes(path), Is.EqualTo(before)); + Assert.That(File.Exists(link + ".lock"), Is.False, "the refused write took the lock"); + } + + [Test] + public void ATryCommitAtALiveLinkWhoseLockIsHeldIsStillRefused() + { + var path = PathOf("store.cc"); + var link = PathOf("link.cc"); + SeedSingle(path); + Link(link, path); + using var cache = CultCacheMessagePack.Create(link, new CultCacheOpenOptions { Registry = Registry, FlushOnDispose = false, StoreFlushOnDispose = false }); + using var held = new FileStream(link + ".lock", FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + + Assert.Throws(() => + cache.TryCommit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "t" }, new CultRecordKey("t")))); + Assert.That(IsALink(link), "the link was replaced"); + } + + // A batch that upserts into one store and removes a key the cache does not hold has the other store judge that removal + // under its own lock (JudgeChecks). + private CultCache OpenTwoStores(string homePath, string otherPath) + { + var cache = new CultCache(Registry); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(homePath), typeof(IdDeck)); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(otherPath), typeof(VectorItem)); + return cache; + } + + [Test] + public void AnotherStoreAtALiveLinkRefusesTheChecksItIsAskedToJudge() + { + var other = PathOf("other.cc"); + var link = PathOf("other-link.cc"); + SeedItem(other); + Link(link, other); + var before = File.ReadAllBytes(other); + using var cache = OpenTwoStores(PathOf("home.cc"), link); + + var refusal = Assert.Throws(() => cache.Commit(batch => + { + batch.Upsert(typeof(IdDeck), new IdDeck { Name = "home" }, new CultRecordKey("home")); + batch.Remove(new CultRecordKey("ghost")); + }))!; + + Assert.That(refusal.Path, Is.EqualTo(link)); + Assert.That(IsALink(link), "the link was replaced"); + Assert.That(File.ReadAllBytes(other), Is.EqualTo(before)); + Assert.That(File.Exists(PathOf("home.cc")), Is.False, "the home store was written though a judge refused"); + } + + [Test] + public void AJudgeReadsTheFileItHoldsUnderTheLockNotTheFileBeforeItWaited() + { + var other = PathOf("other.cc"); + SeedItem(other); + using var cache = OpenTwoStores(PathOf("home.cc"), other); + + var error = WriteWhileWaiting(other + ".lock", + () => cache.Commit(batch => + { + batch.Upsert(typeof(IdDeck), new IdDeck { Name = "home" }, new CultRecordKey("home")); + batch.Remove(new CultRecordKey("ghost")); + }), + () => File.WriteAllBytes(other, Array.Empty())); + + Assert.That(error, Is.InstanceOf(), error?.ToString()); + } + + // ---- the directory store's manifest ---- + + private DirectoryMessagePackBackingStore SeedDirectory(string manifest) + { + using (var seed = new CultCache(Registry)) + { + seed.AddBackingStore(new DirectoryMessagePackBackingStore(manifest), typeof(IdDeck)); + seed.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "seed" }, new CultRecordKey("seed"))); + } + + return new DirectoryMessagePackBackingStore(manifest); + } + + private CultCache OpenDirectory(DirectoryMessagePackBackingStore store) + { + var cache = new CultCache(Registry); + cache.AddBackingStore(store, typeof(IdDeck)); + return cache; + } + + [Test] + public void ADirectoryStoreCommitAtALiveManifestLinkIsRefusedAndNothingChanges() + { + var manifest = PathOf("real.cc"); + var link = PathOf("link.cc"); + SeedDirectory(manifest); + Link(link, manifest); + Directory.Move(manifest + ".records", link + ".records"); + var before = File.ReadAllBytes(manifest); + var pages = Directory.GetFileSystemEntries(link + ".records").OrderBy(entry => entry).ToArray(); + using var cache = OpenDirectory(new DirectoryMessagePackBackingStore(link)); + + var refusal = Assert.Throws(() => + cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "b" }, new CultRecordKey("b"))))!; + + Assert.That(refusal.Path, Is.EqualTo(link)); + Assert.That(IsALink(link), "the link was replaced"); + Assert.That(File.ReadAllBytes(manifest), Is.EqualTo(before)); + Assert.That(Directory.GetFileSystemEntries(link + ".records").OrderBy(entry => entry), Is.EqualTo(pages), "the refused write left files beside the manifest"); + } + + // The refusal comes before the records folder or the lease file is made: a store that was never written leaves nothing behind. + [Test] + public void ADirectoryStoreCommitAtAManifestLinkPutThereAfterOpenCreatesNothing() + { + var link = PathOf("link.cc"); + using var cache = OpenDirectory(new DirectoryMessagePackBackingStore(link)); + Link(link, PathOf("unmounted.cc")); + + Assert.Throws(() => + cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "b" }, new CultRecordKey("b")))); + + Assert.That(IsALink(link), "the link was replaced"); + Assert.That(Directory.Exists(link + ".records"), Is.False, "the refused write made the records folder or its lease"); + } + + [Test] + public void ADirectoryStoreFlushAtALiveManifestLinkIsRefused() + { + var manifest = PathOf("real.cc"); + var link = PathOf("link.cc"); + SeedDirectory(manifest); + Link(link, manifest); + Directory.Move(manifest + ".records", link + ".records"); + var before = File.ReadAllBytes(manifest); + using var cache = OpenDirectory(new DirectoryMessagePackBackingStore(link)); + cache.UpsertAsync(typeof(IdDeck), new IdDeck { Name = "f" }, new CultRecordKey("f")).GetAwaiter().GetResult(); + + Assert.Throws(() => cache.FlushAllBackingStores()); + + Assert.That(IsALink(link), "the link was replaced"); + Assert.That(File.ReadAllBytes(manifest), Is.EqualTo(before)); + } + + [Test] + public void ADirectoryStoreCommitRefusesAManifestLinkPutThereWhileItWaitedOnTheLease() + { + var manifest = PathOf("store.cc"); + var target = PathOf("shared.cc"); + SeedDirectory(manifest); + SeedDirectory(target); + var before = File.ReadAllBytes(target); + using var cache = OpenDirectory(new DirectoryMessagePackBackingStore(manifest)); + + var error = WriteWhileWaiting(Path.Combine(manifest + ".records", ".commit.lock"), + () => cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "late" }, new CultRecordKey("late"))), + () => ReplaceWithLink(manifest, target)); + + Assert.That(error, Is.InstanceOf(), error?.ToString()); + Assert.That(IsALink(manifest), "the link was replaced"); + Assert.That(File.ReadAllBytes(target), Is.EqualTo(before)); + } + + [Test] + public void ADirectoryStoreReadsThroughALiveManifestLink() + { + var manifest = PathOf("real.cc"); + var link = PathOf("link.cc"); + SeedDirectory(manifest); + Link(link, manifest); + Directory.Move(manifest + ".records", link + ".records"); + + using var cache = OpenDirectory(new DirectoryMessagePackBackingStore(link)); + + Assert.That(cache.Get(new CultRecordKey("seed"))?.Name, Is.EqualTo("seed")); + } + } +} diff --git a/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs b/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs index f923dbfb..b244dc5f 100644 --- a/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs +++ b/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs @@ -266,7 +266,7 @@ namespace GameCult.Caching.Tests using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }); CreateDanglingDirectoryLink(path, Path.Combine(_directory, "unmounted")); - Assert.That(() => cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "e" }, new CultRecordKey("e"))), Throws.TypeOf()); + Assert.That(() => cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "e" }, new CultRecordKey("e"))), Throws.TypeOf()); Assert.That(IsALink(path), "the link was replaced"); Assert.That(Directory.Exists(Path.Combine(_directory, "unmounted")), Is.False, "a write went through the link"); } @@ -292,11 +292,66 @@ namespace GameCult.Caching.Tests using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }); File.Delete(target); - Assert.That(() => cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "e" }, new CultRecordKey("e"))), Throws.TypeOf()); + Assert.That(() => cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "e" }, new CultRecordKey("e"))), Throws.TypeOf()); Assert.That(new FileInfo(path).LinkTarget, Is.Not.Null, "the link was replaced"); Assert.That(File.Exists(target), Is.False, "a write went through the link"); } + // A write at a store path that is a link is refused with a typed error before the lock or the replace: the link, its target + // and the directory beside them are as they were. + [Test] + public void ACacheCommitAtALiveLinkIsRefusedAndNothingChanges() + { + var target = Path.Combine(_directory, "target.cc"); + var link = Path.Combine(_directory, "link.cc"); + using (var seed = CultCacheMessagePack.Create(target, new CultCacheOpenOptions { Registry = Registry })) + seed.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "a" }, new CultRecordKey("a"))); + CreateFileLinkOrIgnore(link, target); + var before = File.ReadAllBytes(target); + using var cache = CultCacheMessagePack.Create(link, new CultCacheOpenOptions { Registry = Registry, FlushOnDispose = false, StoreFlushOnDispose = false }); + + var refusal = Assert.Throws(() => cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "b" }, new CultRecordKey("b"))))!; + + Assert.That(refusal.Message, Does.Contain(link)); + Assert.That(IsALink(link), "the link was replaced"); + Assert.That(File.ReadAllBytes(target), Is.EqualTo(before)); + Assert.That(File.Exists(link + ".lock"), Is.False, "the refused write took the lock"); + Assert.That(Directory.GetFileSystemEntries(_directory).Where(entry => entry.EndsWith(".tmp")), Is.Empty); + } + + [Test] + public void AFlushAtALiveLinkIsRefusedAndNothingChanges() + { + var target = Path.Combine(_directory, "target.cc"); + var link = Path.Combine(_directory, "link.cc"); + using (var seed = CultCacheMessagePack.Create(target, new CultCacheOpenOptions { Registry = Registry })) + seed.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "a" }, new CultRecordKey("a"))); + CreateFileLinkOrIgnore(link, target); + var before = File.ReadAllBytes(target); + using var cache = CultCacheMessagePack.Create(link, new CultCacheOpenOptions { Registry = Registry, FlushOnDispose = false, StoreFlushOnDispose = false }); + cache.UpsertAsync(typeof(IdDeck), new IdDeck { Name = "b" }, new CultRecordKey("b")).GetAwaiter().GetResult(); + + Assert.Throws(() => cache.FlushAllBackingStores()); + + Assert.That(IsALink(link), "the link was replaced"); + Assert.That(File.ReadAllBytes(target), Is.EqualTo(before)); + Assert.That(File.Exists(link + ".lock"), Is.False); + } + + [Test] + public void AnOpenAtALiveLinkReadsTheTargetsRecords() + { + var target = Path.Combine(_directory, "target.cc"); + var link = Path.Combine(_directory, "link.cc"); + using (var seed = CultCacheMessagePack.Create(target, new CultCacheOpenOptions { Registry = Registry })) + seed.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "a" }, new CultRecordKey("a"))); + CreateFileLinkOrIgnore(link, target); + + using var cache = CultCacheMessagePack.Create(link, new CultCacheOpenOptions { Registry = Registry, FlushOnDispose = false, StoreFlushOnDispose = false }); + + Assert.That(cache.Get(new CultRecordKey("a"))?.Name, Is.EqualTo("a")); + } + // A directory at the path is something at the path: it is an I/O error on open and on commit, never an empty store, and // it is left as it was. [Test] diff --git a/tests/GameCult.Caching.Tests/StoreWriteReadsUnderTheLockTests.cs b/tests/GameCult.Caching.Tests/StoreWriteReadsUnderTheLockTests.cs new file mode 100644 index 00000000..971f3478 --- /dev/null +++ b/tests/GameCult.Caching.Tests/StoreWriteReadsUnderTheLockTests.cs @@ -0,0 +1,132 @@ +#nullable enable +using System; +using System.IO; +using System.Threading.Tasks; +using GameCult.Caching.MessagePack; +using NUnit.Framework; +using static GameCult.Caching.Tests.ElementIdTests; + +namespace GameCult.Caching.Tests +{ + // A write decides from the file it holds the lock over, not from the file as it was before it waited: whatever another + // writer left while this one waited for .lock is what the write applies onto, or refuses. + public class StoreWriteReadsUnderTheLockTests + { + private static readonly CultDocumentRegistry Registry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) }); + private static readonly CultSchemaCatalogEntry One = new() { SchemaId = "tests.under-lock.one", SchemaName = "tests.under-lock.one" }; + private string _directory = ""; + private string _path = ""; + + [SetUp] + public void SetUp() + { + _directory = Path.Combine(Path.GetTempPath(), "cultlib-under-lock-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_directory); + _path = Path.Combine(_directory, "store.cc"); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_directory)) + Directory.Delete(_directory, true); + } + + private void Seed() + { + using var cache = CultCacheMessagePack.Create(_path, new CultCacheOpenOptions { Registry = Registry }); + cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "seed" }, new CultRecordKey("seed"))); + } + + // The write starts and waits on the held lock; while it waits, the file is replaced by what `swap` makes of it. + private Exception? WriteWhileTheFileChangesUnderIt(Action write, Action swap) + { + using var held = new FileStream(_path + ".lock", FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + var task = Task.Run(write); + Assert.That(task.Wait(TimeSpan.FromMilliseconds(600)), Is.False, "the lock is held, so the write must wait"); + swap(); + held.Dispose(); + try + { + Assert.That(task.Wait(TimeSpan.FromSeconds(10)), Is.True); + return null; + } + catch (AggregateException ex) + { + return ex.GetBaseException(); + } + } + + private static void ZeroBytes(string path) => File.WriteAllBytes(path, Array.Empty()); + + private static CultPersistedStoreSnapshot Snapshot(string path) => + CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); + + private void MarkV3() + { + var snapshot = Snapshot(_path); + snapshot.FormatVersion = CultPersistedStoreSnapshot.FormatV3; + File.WriteAllBytes(_path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + } + + private void CommitWrite(Action swap, out Exception? error) + { + using var cache = CultCacheMessagePack.Create(_path, new CultCacheOpenOptions { Registry = Registry, FlushOnDispose = false, StoreFlushOnDispose = false }); + error = WriteWhileTheFileChangesUnderIt( + () => cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "committed" }, new CultRecordKey("committed"))), swap); + } + + private void FlushWriteWhile(Action swap, out Exception? error) + { + using var cache = CultCacheMessagePack.Create(_path, new CultCacheOpenOptions { Registry = Registry, FlushOnDispose = false, StoreFlushOnDispose = false }); + cache.UpsertAsync(typeof(IdDeck), new IdDeck { Name = "flushed" }, new CultRecordKey("flushed")).GetAwaiter().GetResult(); + error = WriteWhileTheFileChangesUnderIt(() => cache.FlushAllBackingStores(), swap); + } + + private void CachelessWriteWhile(Action swap, out Exception? error) => + error = WriteWhileTheFileChangesUnderIt( + () => new SingleFileMessagePackBackingStore(_path).WriteRecord( + new CultPersistedRecord { Key = "written", SchemaId = One.SchemaId, StoredAt = "2026-10-22T00:00:00.0000000+00:00", Payload = new byte[] { 1 } }, One), + swap); + + private delegate void WriteWhile(Action swap, out Exception? error); + + [Test] + public void AFlushDecidesFromTheFileHeldUnderTheLock() => RefusesWhatItFindsUnderTheLock(FlushWriteWhile); + + [Test] + public void ACommitDecidesFromTheFileHeldUnderTheLock() => RefusesWhatItFindsUnderTheLock(CommitWrite); + + [Test] + public void ACachelessWriteDecidesFromTheFileHeldUnderTheLock() => RefusesWhatItFindsUnderTheLock(CachelessWriteWhile); + + [Test] + public void AFlushKeepsTheIdHeaderOfTheFileHeldUnderTheLock() => KeepsTheV3HeaderItFindsUnderTheLock(FlushWriteWhile); + + [Test] + public void ACommitKeepsTheIdHeaderOfTheFileHeldUnderTheLock() => KeepsTheV3HeaderItFindsUnderTheLock(CommitWrite); + + [Test] + public void ACachelessWriteKeepsTheIdHeaderOfTheFileHeldUnderTheLock() => KeepsTheV3HeaderItFindsUnderTheLock(CachelessWriteWhile); + + private void RefusesWhatItFindsUnderTheLock(WriteWhile write) + { + Seed(); + + write(() => ZeroBytes(_path), out var error); + + Assert.That(error, Is.InstanceOf()); + Assert.That(new FileInfo(_path).Length, Is.Zero, "the refused file was written over"); + } + + private void KeepsTheV3HeaderItFindsUnderTheLock(WriteWhile write) + { + Seed(); + + write(MarkV3, out var error); + + Assert.That(error, Is.Null, error?.ToString()); + Assert.That(Snapshot(_path).FormatVersion, Is.EqualTo(CultPersistedStoreSnapshot.FormatV3), "the write read the file as it was before the lock"); + } + } +} diff --git a/tests/GameCult.Mesh.Tests/CultMeshSingleFileStoreTests.cs b/tests/GameCult.Mesh.Tests/CultMeshSingleFileStoreTests.cs index 7ce94ed2..7e210081 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshSingleFileStoreTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshSingleFileStoreTests.cs @@ -434,32 +434,74 @@ public sealed class CultMeshSingleFileStoreTests TempFiles().Should().BeEmpty(); } - // The store's replace decides what a write leaves where a live link was, and a Mesh write leaves what a cache commit leaves: - // one replace, whatever the platform does with a link. - [Test] - public void AMeshWriteOverALiveLinkLeavesWhatACacheCommitLeaves() + // A write at a store path that is a link is refused before the lock, the staging file and the replace: the link and its + // target stay as they were, and nothing is left beside them. + private void AssertRefusedAndUntouched(string link, string target, byte[] targetBefore, Action write) { - string Outcome(string name, Action write) + var refusal = Assert.Throws(() => write())!; + refusal.Message.Should().Contain(link); + IsALink(link).Should().BeTrue("the link was replaced"); + File.ReadAllBytes(target).Should().Equal(targetBefore); + File.Exists(link + ".lock").Should().BeFalse("the refused write took the lock"); + TempFiles().Should().BeEmpty(); + } + + [Test] + public void AMeshWriteAtALiveLinkIsRefusedAndNothingChanges() + { + var target = Path.Combine(_root, "target.cc"); + var link = Path.Combine(_root, "link.cc"); + CultMesh.WriteSingleFileDocument(target, Key, Publication("first")); + CreateLiveLinkOrIgnore(link, target); + var before = File.ReadAllBytes(target); + + AssertRefusedAndUntouched(link, target, before, () => CultMesh.WriteSingleFileDocument(link, Key, Publication("second"))); + AssertRefusedAndUntouched(link, target, before, () => CultMesh.WriteSingleFileDocumentPayload(link, Key, Raw, null, EmptyArray)); + } + + [Test] + public void AReadThroughALiveLinkStillWorks() + { + var target = Path.Combine(_root, "target.cc"); + var link = Path.Combine(_root, "link.cc"); + CultMesh.WriteSingleFileDocument(target, Key, Publication("first")); + CreateLiveLinkOrIgnore(link, target); + + CultMesh.ReadSingleFileDocument(link, Key).ProducerId.Should().Be("first"); + RecordsSeenByACache(link).Should().Be(1); + } + + // A link to a directory that is there is a directory, not an unreachable store; a write at it is a link refusal. + [Test] + public void ALiveDirectoryLinkIsADirectoryErrorOnReadAndALinkRefusalOnWrite() + { + var directory = Path.Combine(_root, "real-directory"); + var link = Path.Combine(_root, "directory-link.cc"); + Directory.CreateDirectory(directory); + CreateDirectoryLink(link, directory); + + Assert.Catch(() => CultMesh.ReadSingleFileDocumentPayload(link, Key, "raw:schema"))!.Message.Should().Contain("directory"); + Assert.Catch(() => RecordsSeenByACache(link))!.Message.Should().Contain("directory"); + Assert.Throws(() => CultMesh.WriteSingleFileDocument(link, Key, Publication())); + Directory.GetFileSystemEntries(directory).Should().BeEmpty(); + IsALink(link).Should().BeTrue(); + } + + private static void CreateDirectoryLink(string path, string target) + { + if (!OperatingSystem.IsWindows()) { - var target = Path.Combine(_root, name + "-target.cc"); - var link = Path.Combine(_root, name + "-link.cc"); - CultMesh.WriteSingleFileDocument(target, Key, Publication("first")); - CreateLiveLinkOrIgnore(link, target); - var before = File.ReadAllBytes(target); - - write(link); - - CultMesh.ReadSingleFileDocument(link, Key).ProducerId.Should().Be("second", name); - return $"linkKept={IsALink(link)} targetChanged={!File.ReadAllBytes(target).SequenceEqual(before)}"; + Directory.CreateSymbolicLink(path, target); + return; } - var viaMesh = Outcome("mesh", link => CultMesh.WriteSingleFileDocument(link, Key, Publication("second"))); - var viaCache = Outcome("cache", link => + var mklink = Process.Start(new ProcessStartInfo("cmd.exe", $"/c mklink /J \"{path}\" \"{target}\"") { - using var cache = CultCacheMessagePack.Create(link, ReadOnlyOpen); - cache.Commit(batch => batch.Upsert(typeof(CultMeshBodyPublicationDocument), Publication("second"), Key)); - }); - - viaMesh.Should().Be(viaCache); + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + })!; + mklink.WaitForExit(); + Assert.That(mklink.ExitCode, Is.Zero, "mklink /J failed"); } } From a85c63712bc14e17df58df1f3d957de5250e605a Mon Sep 17 00:00:00 2001 From: Libby Date: Fri, 13 Nov 2026 00:38:07 +0000 Subject: [PATCH 70/93] C#: store replace on Windows tries POSIX-semantics rename first (store-windows-cs) (#152) --- .../DirectoryMessagePackBackingStore.cs | 31 +- src/GameCult.Caching/CultCache.cs | 9 +- src/GameCult.Caching/StoreReplace.cs | 182 ++++++++++++ .../StoreReplaceTests.cs | 268 ++++++++++++++++++ .../CultMeshSingleFileStoreTests.cs | 22 +- 5 files changed, 466 insertions(+), 46 deletions(-) create mode 100644 src/GameCult.Caching/StoreReplace.cs create mode 100644 tests/GameCult.Caching.Tests/StoreReplaceTests.cs diff --git a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs index f916a13b..aeec2e21 100644 --- a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs +++ b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs @@ -681,14 +681,7 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore stream.Flush(true); } - if (File.Exists(path)) - { - ReplaceExistingFile(tempPath, path); - } - else - { - File.Move(tempPath, path); - } + StoreReplace.Replace(tempPath, path); } finally { @@ -768,26 +761,4 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore return held; } } - - private static void ReplaceExistingFile(string sourcePath, string destinationPath) - { - for (var attempt = 0; attempt < 3; attempt++) - { - try - { - File.Replace(sourcePath, destinationPath, null); - return; - } - catch (IOException) when (attempt < 2) - { - Thread.Sleep(10 * (attempt + 1)); - } - catch (UnauthorizedAccessException) when (attempt < 2) - { - Thread.Sleep(10 * (attempt + 1)); - } - } - - File.Replace(sourcePath, destinationPath, null); - } } diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index c461bc67..76010354 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -5219,14 +5219,7 @@ namespace GameCult.Caching stream.Flush(true); } - if (File.Exists(path)) - { - File.Replace(tempPath, path, null); - } - else - { - File.Move(tempPath, path); - } + StoreReplace.Replace(tempPath, path); } finally { diff --git a/src/GameCult.Caching/StoreReplace.cs b/src/GameCult.Caching/StoreReplace.cs new file mode 100644 index 00000000..5f16322c --- /dev/null +++ b/src/GameCult.Caching/StoreReplace.cs @@ -0,0 +1,182 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; + +namespace GameCult.Caching +{ + /// The kernel32 calls a Windows replace needs, each answering 0 or a Win32 error code. + internal interface IReplaceKernel + { + /// CreateFileW(staged, DELETE|SYNCHRONIZE), FileRenameInfoEx with REPLACE_IF_EXISTS|POSIX_SEMANTICS, CloseHandle. + int RenamePosix(string staged, string destination); + + /// MoveFileExW(staged, destination, flags). + int MoveFileEx(string staged, string destination, uint flags); + + void Sleep(int milliseconds); + } + + /// + /// Owns how a staged file becomes the store, for the single-file and directory stores. Windows tries a POSIX-semantics + /// rename first (which makes a moment with the path absent rare for a lock-free reader, not impossible: a stalled rename was + /// measured leaving it absent for 5-47 ms), falls back to write-through MoveFileExW where that is unsupported or does not + /// resolve, falls back to the portable File.Replace if MoveFileExW itself does not resolve, and retries transient sharing + /// errors; other OSes keep rename. Mirrors cultcache-ts store-file.ts + /// replaceStoreFile (ruling variants:ruling:windows-replace-posix-first). + /// + internal static class StoreReplace + { + private const int ErrorAccessDenied = 5; + private const int ErrorSharingViolation = 32; + private const int ErrorLockViolation = 33; + private const int ErrorNotSupported = 50; + private const int ErrorInvalidParameter = 87; + private const int ReplaceAttempts = 25; + private const int ReplaceRetryMs = 20; + + /// MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH. + internal const uint MoveFileReplaceWriteThrough = 0x9; + + internal static readonly IReplaceKernel WindowsKernel = new Kernel32Replace(); + + public static void Replace(string staged, string destination) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + ReplaceWindows(staged, destination, WindowsKernel); + } + else + { + ReplacePortable(staged, destination); + } + } + + private static void ReplacePortable(string staged, string destination) + { + if (File.Exists(destination)) + { + File.Replace(staged, destination, null); + } + else + { + File.Move(staged, destination); + } + } + + private static bool Unresolved(Exception ex) => ex is DllNotFoundException || ex is EntryPointNotFoundException; + + internal static void ReplaceWindows(string staged, string destination, IReplaceKernel kernel) + { + var posix = true; + var lastCode = 0; + for (var attempt = 1; attempt <= ReplaceAttempts; attempt++) + { + int code; + try + { + code = posix ? RenamePosixOrUnsupported(kernel, staged, destination) : kernel.MoveFileEx(staged, destination, MoveFileReplaceWriteThrough); + if (posix && (code == 1 || code == ErrorNotSupported || code == ErrorInvalidParameter)) + { + // POSIX semantics are unsupported here (FAT, exFAT, before Windows 10 1607): the rest of this call is write-through. + posix = false; + code = kernel.MoveFileEx(staged, destination, MoveFileReplaceWriteThrough); + } + } + catch (Exception ex) when (Unresolved(ex)) + { + // kernel32's MoveFileExW does not resolve here (a sandboxed player): the runtime-owned replace is all that is left. + ReplacePortable(staged, destination); + return; + } + + if (code == 0) + { + return; + } + + if (code != ErrorAccessDenied && code != ErrorSharingViolation && code != ErrorLockViolation) + { + throw new IOException($"CultCache could not replace the store: Win32 error {code}."); + } + + lastCode = code; + kernel.Sleep(ReplaceRetryMs); + } + + throw new IOException($"CultCache could not replace the store after {ReplaceAttempts} attempts: last Win32 error {lastCode}."); + } + + private static int RenamePosixOrUnsupported(IReplaceKernel kernel, string staged, string destination) + { + try + { + return kernel.RenamePosix(staged, destination); + } + catch (Exception ex) when (Unresolved(ex)) + { + // The POSIX call does not resolve: the same answer as "unsupported", so the replace falls back to MoveFileExW. + return ErrorNotSupported; + } + } + + private sealed class Kernel32Replace : IReplaceKernel + { + private const uint DeleteSynchronize = 0x00110000; + private const uint ShareAll = 0x7; + private const uint OpenExisting = 3; + private const uint FileAttributeNormal = 0x80; + private const int FileRenameInfoEx = 22; + private const uint RenameReplacePosix = 0x3; + + public int RenamePosix(string staged, string destination) + { + var handle = CreateFileW(staged, DeleteSynchronize, ShareAll, IntPtr.Zero, OpenExisting, FileAttributeNormal, IntPtr.Zero); + if (handle == new IntPtr(-1)) + { + return Marshal.GetLastWin32Error(); + } + + try + { + // FILE_RENAME_INFO: Flags u32 at 0, RootDirectory handle, FileNameLength u32, FileName (UTF-16, offsets for this pointer size). + var name = System.Text.Encoding.Unicode.GetBytes(Path.GetFullPath(destination)); + var lengthOffset = 2 * IntPtr.Size; + var nameOffset = lengthOffset + 4; + var size = nameOffset + name.Length + 2; + var info = Marshal.AllocHGlobal(size); + try + { + for (var i = 0; i < size; i++) Marshal.WriteByte(info, i, 0); + Marshal.WriteInt32(info, 0, (int)RenameReplacePosix); + Marshal.WriteInt32(info, lengthOffset, name.Length); + Marshal.Copy(name, 0, info + nameOffset, name.Length); + return SetFileInformationByHandle(handle, FileRenameInfoEx, info, (uint)size) ? 0 : Marshal.GetLastWin32Error(); + } + finally { Marshal.FreeHGlobal(info); } + } + finally { CloseHandle(handle); } + } + + public int MoveFileEx(string staged, string destination, uint flags) + => MoveFileExW(staged, destination, flags) ? 0 : Marshal.GetLastWin32Error(); + + public void Sleep(int milliseconds) => Thread.Sleep(milliseconds); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern IntPtr CreateFileW(string fileName, uint access, uint share, IntPtr security, uint disposition, uint flags, IntPtr template); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetFileInformationByHandle(IntPtr handle, int informationClass, IntPtr information, uint size); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CloseHandle(IntPtr handle); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool MoveFileExW(string existing, string target, uint flags); + } + } +} diff --git a/tests/GameCult.Caching.Tests/StoreReplaceTests.cs b/tests/GameCult.Caching.Tests/StoreReplaceTests.cs new file mode 100644 index 00000000..5f71b4a3 --- /dev/null +++ b/tests/GameCult.Caching.Tests/StoreReplaceTests.cs @@ -0,0 +1,268 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; +using NUnit.Framework; + +namespace GameCult.Caching.Tests +{ + public class StoreReplaceTests + { + private sealed class FakeKernel : IReplaceKernel + { + private readonly Queue _posix; + private readonly Queue _moveEx; + public readonly List Log = new List(); + public readonly List Sleeps = new List(); + public Exception? PosixThrows; + public Exception? MoveExThrows; + + public FakeKernel(int[] posix, int[] moveEx) + { + _posix = new Queue(posix); + _moveEx = new Queue(moveEx); + } + + public int RenamePosix(string staged, string destination) + { + Log.Add("posix"); + if (PosixThrows != null) throw PosixThrows; + return _posix.Count > 0 ? _posix.Dequeue() : 0; + } + + public int MoveFileEx(string staged, string destination, uint flags) + { + Log.Add("moveex:" + flags); + if (MoveExThrows != null) throw MoveExThrows; + return _moveEx.Count > 0 ? _moveEx.Dequeue() : 0; + } + + public void Sleep(int milliseconds) => Sleeps.Add(milliseconds); + } + + private static IOException Fails(FakeKernel kernel) + => Assert.Throws(() => StoreReplace.ReplaceWindows("a", "b", kernel))!; + + [Test] + public void APosixRenameThatWorksIsTheWholeReplace() + { + var kernel = new FakeKernel(new[] { 0 }, new int[0]); + StoreReplace.ReplaceWindows("a", "b", kernel); + Assert.That(kernel.Log, Is.EqualTo(new[] { "posix" })); + Assert.That(kernel.Sleeps, Is.Empty); + } + + [TestCase(1)] + [TestCase(50)] + [TestCase(87)] + public void WhereAPosixRenameIsUnsupportedTheReplaceFallsBackToWriteThroughMoveFileEx(int unsupported) + { + var kernel = new FakeKernel(new[] { unsupported }, new[] { 0 }); + StoreReplace.ReplaceWindows("a", "b", kernel); + Assert.That(kernel.Log, Is.EqualTo(new[] { "posix", "moveex:9" })); + } + + [Test] + public void OnceThePosixRenameIsUnsupportedTheRestOfTheCallStaysOnMoveFileEx() + { + var kernel = new FakeKernel(new[] { 87 }, new[] { 32, 0 }); + StoreReplace.ReplaceWindows("a", "b", kernel); + Assert.That(kernel.Log, Is.EqualTo(new[] { "posix", "moveex:9", "moveex:9" })); + Assert.That(kernel.Sleeps, Is.EqualTo(new[] { 20 })); + } + + [TestCase(5)] + [TestCase(32)] + [TestCase(33)] + public void ATransientErrorRetriesTheSamePrimitiveAfterTwentyMilliseconds(int transient) + { + var kernel = new FakeKernel(new[] { transient, transient, 0 }, new int[0]); + StoreReplace.ReplaceWindows("a", "b", kernel); + Assert.That(kernel.Log, Is.EqualTo(new[] { "posix", "posix", "posix" })); + Assert.That(kernel.Sleeps, Is.EqualTo(new[] { 20, 20 })); + } + + [TestCase(1)] + [TestCase(50)] + [TestCase(87)] + public void AnUnsupportedCodeAfterTheFallbackIsAnErrorNotAnotherFallback(int unsupported) + { + var kernel = new FakeKernel(new[] { 87 }, new[] { unsupported }); + var error = Fails(kernel); + Assert.That(error.Message, Does.Contain("Win32 error " + unsupported)); + Assert.That(kernel.Log, Is.EqualTo(new[] { "posix", "moveex:9" })); + } + + private static IEnumerable Unresolved() + { + yield return new DllNotFoundException("kernel32.dll"); + yield return new EntryPointNotFoundException("SetFileInformationByHandle"); + } + + [TestCaseSource(nameof(Unresolved))] + public void APosixCallThatDoesNotResolveTakesTheUnsupportedFallback(Exception unresolved) + { + var kernel = new FakeKernel(new int[0], new[] { 0 }) { PosixThrows = unresolved }; + StoreReplace.ReplaceWindows("a", "b", kernel); + Assert.That(kernel.Log, Is.EqualTo(new[] { "posix", "moveex:9" })); + } + + [TestCaseSource(nameof(Unresolved))] + public void AMoveFileExThatDoesNotResolveDegradesToThePortableReplace(Exception unresolved) + { + var dir = Path.Combine(TestContext.CurrentContext.WorkDirectory, "storereplace-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + var destination = Path.Combine(dir, "store.cc"); + var staged = Path.Combine(dir, "staged"); + File.WriteAllBytes(destination, new byte[] { 1 }); + File.WriteAllBytes(staged, new byte[] { 2 }); + var kernel = new FakeKernel(new[] { 87 }, new int[0]) { MoveExThrows = unresolved }; + StoreReplace.ReplaceWindows(staged, destination, kernel); + Assert.That(kernel.Log, Is.EqualTo(new[] { "posix", "moveex:9" })); + Assert.That(File.ReadAllBytes(destination), Is.EqualTo(new byte[] { 2 })); + Assert.That(File.Exists(staged), Is.False); + + // Both calls unresolved: the same portable replace, here onto an absent destination. + File.Delete(destination); + File.WriteAllBytes(staged, new byte[] { 3 }); + var both = new FakeKernel(new int[0], new int[0]) { PosixThrows = unresolved, MoveExThrows = unresolved }; + StoreReplace.ReplaceWindows(staged, destination, both); + Assert.That(File.ReadAllBytes(destination), Is.EqualTo(new byte[] { 3 })); + } + finally { Directory.Delete(dir, true); } + } + + [Test] + public void TheFallbackRunsInTheSameAttemptEvenOnTheLastOne() + { + var posix = new int[25].Fill(32); + posix[24] = 87; + var kernel = new FakeKernel(posix, new[] { 0 }); + StoreReplace.ReplaceWindows("a", "b", kernel); + Assert.That(kernel.Log.Count, Is.EqualTo(26)); + Assert.That(kernel.Log[24], Is.EqualTo("posix")); + Assert.That(kernel.Log[25], Is.EqualTo("moveex:9")); + } + + [Test] + public void ARetriedMoveFileExThatAnswersUnsupportedFailsAfterOneCallNotTwo() + { + var kernel = new FakeKernel(new[] { 87 }, new[] { 32, 87 }); + Assert.That(Fails(kernel).Message, Does.Contain("Win32 error 87")); + Assert.That(kernel.Log, Is.EqualTo(new[] { "posix", "moveex:9", "moveex:9" })); + } + + [Test] + public void ARetryThatNeverSucceedsGivesUpAfterTwentyFiveAttemptsNamingTheCode() + { + var kernel = new FakeKernel(new int[30].Fill(32), new int[0]); + var error = Fails(kernel); + Assert.That(error.Message, Does.Contain("25 attempts").And.Contain("last Win32 error 32")); + Assert.That(kernel.Log.Count, Is.EqualTo(25)); + Assert.That(kernel.Sleeps.Count, Is.EqualTo(25)); + } + + [Test] + public void AnyOtherCodeThrowsAtOnceNamingTheCode() + { + var kernel = new FakeKernel(new[] { 2 }, new int[0]); + var error = Fails(kernel); + Assert.That(error.Message, Does.Contain("Win32 error 2")); + Assert.That(kernel.Log.Count, Is.EqualTo(1)); + Assert.That(kernel.Sleeps, Is.Empty); + } + + [Test] + public void AFallbackThatFailsOtherwiseThrowsItsCode() + { + var kernel = new FakeKernel(new[] { 87 }, new[] { 3 }); + Assert.That(Fails(kernel).Message, Does.Contain("Win32 error 3")); + } + + [Test] + public void ReplaceOverAnExistingAndThenAnAbsentDestinationLeavesTheStagedBytes() + { + var dir = Path.Combine(TestContext.CurrentContext.WorkDirectory, "storereplace-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + var destination = Path.Combine(dir, "store.cc"); + var staged = Path.Combine(dir, "staged"); + File.WriteAllBytes(destination, new byte[] { 1 }); + File.WriteAllBytes(staged, new byte[] { 2 }); + StoreReplace.Replace(staged, destination); + Assert.That(File.ReadAllBytes(destination), Is.EqualTo(new byte[] { 2 })); + Assert.That(File.Exists(staged), Is.False); + + File.Delete(destination); + File.WriteAllBytes(staged, new byte[] { 3 }); + StoreReplace.Replace(staged, destination); + Assert.That(File.ReadAllBytes(destination), Is.EqualTo(new byte[] { 3 })); + } + finally { Directory.Delete(dir, true); } + } + + [Test] + public void OnWindowsAReplaceOverADestinationAReaderHoldsOpenSucceedsOnTheFirstAttempt() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + Assert.Ignore("The real kernel runs on Windows only."); + + var dir = Path.Combine(TestContext.CurrentContext.WorkDirectory, "storereplace-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + var destination = Path.Combine(dir, "store.cc"); + var staged = Path.Combine(dir, "staged"); + File.WriteAllBytes(destination, new byte[] { 1 }); + File.WriteAllBytes(staged, new byte[] { 2 }); + using (new FileStream(destination, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete)) + { + var kernel = new CountingKernel(StoreReplace.WindowsKernel); + StoreReplace.ReplaceWindows(staged, destination, kernel); + Assert.That(kernel.PosixCalls, Is.EqualTo(1)); + Assert.That(kernel.MoveExCalls, Is.EqualTo(0)); + Assert.That(kernel.Sleeps, Is.EqualTo(0)); + Assert.That(kernel.LastCode, Is.EqualTo(0)); + } + + Assert.That(File.ReadAllBytes(destination), Is.EqualTo(new byte[] { 2 })); + } + finally { Directory.Delete(dir, true); } + } + + private sealed class CountingKernel : IReplaceKernel + { + private readonly IReplaceKernel _inner; + public int PosixCalls, MoveExCalls, Sleeps, LastCode; + public CountingKernel(IReplaceKernel inner) { _inner = inner; } + + public int RenamePosix(string staged, string destination) + { + PosixCalls++; + return LastCode = _inner.RenamePosix(staged, destination); + } + + public int MoveFileEx(string staged, string destination, uint flags) + { + MoveExCalls++; + return LastCode = _inner.MoveFileEx(staged, destination, flags); + } + + public void Sleep(int milliseconds) { Sleeps++; _inner.Sleep(milliseconds); } + } + } + + internal static class ArrayFill + { + public static int[] Fill(this int[] array, int value) + { + for (var i = 0; i < array.Length; i++) array[i] = value; + return array; + } + } +} diff --git a/tests/GameCult.Mesh.Tests/CultMeshSingleFileStoreTests.cs b/tests/GameCult.Mesh.Tests/CultMeshSingleFileStoreTests.cs index 7e210081..19c08a4b 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshSingleFileStoreTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshSingleFileStoreTests.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; +using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using FluentAssertions; @@ -364,11 +365,11 @@ public sealed class CultMeshSingleFileStoreTests [Test] public void ACacheOpeningDuringMeshWritesNeverSeesAnEmptyStore() { - // Windows replaces with ReplaceFile, which leaves a moment with nothing at the path for a lock-free reader: 1,722 empty - // stores in 17,313 opens at b1ff2dcb. That is the store's replace, not Mesh's, and it is the same for a cache flush beside a - // cache open; the fork is in the cut report. - if (OperatingSystem.IsWindows()) - Assert.Ignore("Windows File.Replace is not atomic to a lock-free reader; see cut report fork mesh-reader-windows-replace-window."); + // Windows replaces with a POSIX-semantics rename first (StoreReplace), which makes a lock-free reader finding the path + // absent rare, not impossible: a stalled rename was measured leaving it absent for 5-47 ms on Windows. So on Windows a + // brief not-found (a read throwing, or a cache opening onto an absent path) is tolerated and reported; elsewhere rename + // is atomic and none is allowed. + var windows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); var path = Path.Combine(_root, "race.cc"); CultMesh.WriteSingleFileDocument(path, Key, Publication()); @@ -386,7 +387,7 @@ public sealed class CultMeshSingleFileStoreTests Volatile.Write(ref stop, 1); }); - int opens = 0, empty = 0, reads = 0; + int opens = 0, empty = 0, reads = 0, absent = 0; var clock = Stopwatch.StartNew(); while (Volatile.Read(ref stop) == 0 && clock.Elapsed < TimeSpan.FromSeconds(60)) { @@ -397,6 +398,10 @@ public sealed class CultMeshSingleFileStoreTests CultMesh.ReadSingleFileDocumentPayload(path, Key, SchemaId()); reads++; } + catch (Exception ex) when (windows && (ex is FileNotFoundException || ex is DirectoryNotFoundException)) + { + absent++; + } catch (Exception ex) { errors.Enqueue("read: " + ex.GetType().Name + ": " + ex.Message); @@ -404,9 +409,10 @@ public sealed class CultMeshSingleFileStoreTests } writer.Wait(TimeSpan.FromSeconds(60)).Should().BeTrue(); - TestContext.Out.WriteLine($"mesh writes={writes} cache opens={opens} empty stores={empty} mesh reads ok={reads} errors={errors.Count}"); + TestContext.Out.WriteLine($"mesh writes={writes} cache opens={opens} empty stores={empty} not-found reads={absent} mesh reads ok={reads} errors={errors.Count}"); errors.Take(3).Should().BeEmpty(); - empty.Should().Be(0, "a cache opened in the window where nothing was at the path"); + if (!windows) + empty.Should().Be(0, "a cache opened in the window where nothing was at the path"); opens.Should().BeGreaterThan(0); } From 845c024379dc88bad3a9d6cba16067ac772f31e8 Mon Sep 17 00:00:00 2001 From: Libby Date: Fri, 13 Nov 2026 01:29:21 +0000 Subject: [PATCH 71/93] Rust: store replace on Windows tries POSIX-semantics rename first (store-windows-rs) (#148) --- packages/cultcache-rs/Cargo.toml | 2 +- packages/cultcache-rs/src/lib.rs | 344 ++++++++++++++++++++++++++----- 2 files changed, 296 insertions(+), 50 deletions(-) diff --git a/packages/cultcache-rs/Cargo.toml b/packages/cultcache-rs/Cargo.toml index 7954370c..db8a46ac 100644 --- a/packages/cultcache-rs/Cargo.toml +++ b/packages/cultcache-rs/Cargo.toml @@ -31,4 +31,4 @@ pretty_assertions = "1.4" tempfile = "3" [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Storage_FileSystem"] } +windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_Security", "Win32_System_WindowsProgramming"] } diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index e3e47e43..d4d6a64e 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -4396,61 +4396,277 @@ fn replace_file_atomically(staged: &Path, destination: &Path) -> Result<()> { #[cfg(windows)] fn replace_file_atomically(staged: &Path, destination: &Path) -> Result<()> { - use std::os::windows::ffi::OsStrExt; - use windows_sys::Win32::Storage::FileSystem::{ - MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW, - }; - injected_write_fault(WriteStep::Rename).context("failed to atomically replace the store")?; + replace_with(&WindowsKernel, staged, destination) +} - let staged_wide = staged - .as_os_str() - .encode_wide() - .chain(std::iter::once(0)) - .collect::>(); - let destination_wide = destination - .as_os_str() - .encode_wide() - .chain(std::iter::once(0)) - .collect::>(); - // A concurrent reader of the destination (another process opening the - // store, an indexer, an antivirus scan) makes the replace fail with a - // sharing or access error for as long as it holds the handle. That is - // transient by construction; a daemon that treats it as fatal dies on - // the first overlap (Muninn serve did, 2026-09-11). Retry briefly. - const ERROR_ACCESS_DENIED: i32 = 5; - const ERROR_SHARING_VIOLATION: i32 = 32; - const ERROR_LOCK_VIOLATION: i32 = 33; +/// The two Windows replace primitives, each answering 0 or a Win32 error code, and the wait +/// between attempts. `replace_with` owns every decision about them. +#[cfg_attr(not(windows), allow(dead_code))] +trait ReplaceKernel { + /// CreateFileW(staged, DELETE|SYNCHRONIZE), then FileRenameInfoEx with + /// REPLACE_IF_EXISTS|POSIX_SEMANTICS, then CloseHandle. + fn rename_posix(&self, staged: &Path, destination: &Path) -> u32; + /// MoveFileExW(staged, destination, flags). + fn move_file_ex(&self, staged: &Path, destination: &Path, flags: u32) -> u32; + fn sleep(&self, milliseconds: u64); +} + +/// MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH. +#[cfg_attr(not(windows), allow(dead_code))] +const MOVEFILE_REPLACE_WRITE_THROUGH: u32 = 0x9; + +/// Replaces `destination` with `staged` on Windows: a POSIX-semantics rename first, so a reader +/// that opened the old store keeps it and the destination is rarely seen absent (a stalled rename +/// can still leave it absent for 5-47 ms on NTFS: `variants:ruling:posix-rename-absent-window-document`); +/// MoveFileExW with +/// write-through where POSIX semantics are unsupported (FAT, exFAT, before Windows 10 1607), +/// for the rest of the call; and a bounded retry on access, sharing and lock violations. +/// A concurrent reader (another process, an indexer, an antivirus scan) makes the replace fail +/// transiently, and a daemon that treats that as fatal dies on the first overlap (Muninn serve +/// did, 2026-09-11). Ruling `variants:ruling:windows-replace-posix-first`: cultcache-ts carries the +/// same table on main; cultcache-cs carries it only once its pending PR lands. +#[cfg_attr(not(windows), allow(dead_code))] +fn replace_with(kernel: &dyn ReplaceKernel, staged: &Path, destination: &Path) -> Result<()> { + const ERROR_INVALID_FUNCTION: u32 = 1; + const ERROR_ACCESS_DENIED: u32 = 5; + const ERROR_SHARING_VIOLATION: u32 = 32; + const ERROR_LOCK_VIOLATION: u32 = 33; + const ERROR_NOT_SUPPORTED: u32 = 50; + const ERROR_INVALID_PARAMETER: u32 = 87; const ATTEMPTS: u32 = 25; - const BACKOFF: std::time::Duration = std::time::Duration::from_millis(20); - let mut attempt = 0; - loop { - let success = unsafe { - MoveFileExW( - staged_wide.as_ptr(), - destination_wide.as_ptr(), - MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, - ) + const BACKOFF_MS: u64 = 20; + let mut posix = true; + let mut last_code = 0; + for attempt in 1..=ATTEMPTS { + let mut code = if posix { + kernel.rename_posix(staged, destination) + } else { + kernel.move_file_ex(staged, destination, MOVEFILE_REPLACE_WRITE_THROUGH) }; - if success != 0 { + if posix && matches!(code, ERROR_INVALID_FUNCTION | ERROR_NOT_SUPPORTED | ERROR_INVALID_PARAMETER) { + posix = false; + code = kernel.move_file_ex(staged, destination, MOVEFILE_REPLACE_WRITE_THROUGH); + } + if code == 0 { return Ok(()); } - let error = std::io::Error::last_os_error(); - attempt += 1; - let transient = matches!( - error.raw_os_error(), - Some(ERROR_ACCESS_DENIED | ERROR_SHARING_VIOLATION | ERROR_LOCK_VIOLATION) - ); - if !transient || attempt >= ATTEMPTS { - return Err(error).with_context(|| { - format!( - "failed to atomically replace {} with {} after {attempt} attempt(s)", - destination.display(), - staged.display() - ) - }); + if !matches!(code, ERROR_ACCESS_DENIED | ERROR_SHARING_VIOLATION | ERROR_LOCK_VIOLATION) { + return Err(anyhow::anyhow!( + "failed to atomically replace {} with {}: Win32 error {code} after {attempt} attempt(s)", + destination.display(), + staged.display() + )); } - std::thread::sleep(BACKOFF); + last_code = code; + kernel.sleep(BACKOFF_MS); + } + Err(anyhow::anyhow!( + "failed to atomically replace {} with {} after {ATTEMPTS} attempt(s): last Win32 error {last_code}", + destination.display(), + staged.display() + )) +} + +#[cfg(windows)] +struct WindowsKernel; + +#[cfg(windows)] +fn wide(path: &Path) -> Vec { + use std::os::windows::ffi::OsStrExt; + path.as_os_str().encode_wide().chain(std::iter::once(0)).collect() +} + +#[cfg(windows)] +impl ReplaceKernel for WindowsKernel { + fn rename_posix(&self, staged: &Path, destination: &Path) -> u32 { + use windows_sys::Win32::Foundation::{CloseHandle, GetLastError, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::Storage::FileSystem::{ + CreateFileW, FileRenameInfoEx, SetFileInformationByHandle, DELETE, FILE_FLAG_BACKUP_SEMANTICS, + FILE_RENAME_INFO, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, SYNCHRONIZE, + }; + use windows_sys::Win32::System::WindowsProgramming::{ + FILE_RENAME_FLAG_POSIX_SEMANTICS, FILE_RENAME_FLAG_REPLACE_IF_EXISTS, + }; + + let staged_wide = wide(staged); + let destination_wide = wide(destination); + // FILE_RENAME_INFO ends in a variable-length name: allocate it as u64s for alignment. + let name_bytes = (destination_wide.len() - 1) * 2; + let header = std::mem::size_of::(); + let total = header + name_bytes + 2; + let mut buffer = vec![0u64; total.div_ceil(8)]; + unsafe { + let info = buffer.as_mut_ptr() as *mut FILE_RENAME_INFO; + (*info).Anonymous.Flags = FILE_RENAME_FLAG_REPLACE_IF_EXISTS | FILE_RENAME_FLAG_POSIX_SEMANTICS; + (*info).RootDirectory = std::ptr::null_mut(); + (*info).FileNameLength = name_bytes as u32; + let name = std::ptr::addr_of_mut!((*info).FileName) as *mut u16; + std::ptr::copy_nonoverlapping(destination_wide.as_ptr(), name, destination_wide.len()); + + let handle = CreateFileW( + staged_wide.as_ptr(), + DELETE | SYNCHRONIZE, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + std::ptr::null(), + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS, + std::ptr::null_mut(), + ); + if handle == INVALID_HANDLE_VALUE { + return GetLastError(); + } + let ok = SetFileInformationByHandle(handle, FileRenameInfoEx, info as *const _, total as u32); + let code = if ok == 0 { GetLastError() } else { 0 }; + CloseHandle(handle); + code + } + } + + fn move_file_ex(&self, staged: &Path, destination: &Path, flags: u32) -> u32 { + use windows_sys::Win32::Foundation::GetLastError; + use windows_sys::Win32::Storage::FileSystem::MoveFileExW; + let ok = unsafe { MoveFileExW(wide(staged).as_ptr(), wide(destination).as_ptr(), flags) }; + if ok != 0 { + 0 + } else { + unsafe { GetLastError() } + } + } + + fn sleep(&self, milliseconds: u64) { + std::thread::sleep(std::time::Duration::from_millis(milliseconds)); + } +} + +#[cfg(test)] +mod replace_with_tests { + use super::{replace_with, ReplaceKernel}; + use std::cell::RefCell; + use std::collections::VecDeque; + use std::path::Path; + + #[derive(Debug, PartialEq)] + enum Call { + Posix, + MoveFile(u32), + Sleep(u64), + } + + /// Answers each primitive from a script; an empty script answers 0. + #[derive(Default)] + struct FakeKernel { + posix: RefCell>, + move_file: RefCell>, + calls: RefCell>, + } + + impl FakeKernel { + fn scripted(posix: &[u32], move_file: &[u32]) -> Self { + Self { + posix: RefCell::new(posix.iter().copied().collect()), + move_file: RefCell::new(move_file.iter().copied().collect()), + ..Self::default() + } + } + fn calls(&self) -> Vec { + self.calls.replace(Vec::new()) + } + } + + impl ReplaceKernel for FakeKernel { + fn rename_posix(&self, _: &Path, _: &Path) -> u32 { + self.calls.borrow_mut().push(Call::Posix); + self.posix.borrow_mut().pop_front().unwrap_or(0) + } + fn move_file_ex(&self, _: &Path, _: &Path, flags: u32) -> u32 { + self.calls.borrow_mut().push(Call::MoveFile(flags)); + self.move_file.borrow_mut().pop_front().unwrap_or(0) + } + fn sleep(&self, milliseconds: u64) { + self.calls.borrow_mut().push(Call::Sleep(milliseconds)); + } + } + + fn run(kernel: &FakeKernel) -> anyhow::Result<()> { + replace_with(kernel, Path::new("staged"), Path::new("store.cc")) + } + + #[test] + fn a_posix_rename_that_lands_is_the_whole_replace() { + let kernel = FakeKernel::scripted(&[0], &[]); + run(&kernel).unwrap(); + assert_eq!(kernel.calls(), vec![Call::Posix]); + } + + #[test] + fn unsupported_posix_semantics_fall_back_to_write_through_and_stay_there() { + for code in [1, 50, 87] { + // The fallback itself is held once, so the retry must use MoveFileExW, not POSIX again. + let kernel = FakeKernel::scripted(&[code], &[32, 0]); + run(&kernel).unwrap_or_else(|e| panic!("code {code}: {e}")); + assert_eq!( + kernel.calls(), + vec![Call::Posix, Call::MoveFile(0x9), Call::Sleep(20), Call::MoveFile(0x9)], + "code {code}" + ); + } + } + + #[test] + fn a_held_destination_is_retried_with_posix_until_it_lets_go() { + for code in [5, 32, 33] { + let kernel = FakeKernel::scripted(&[code, code, 0], &[]); + run(&kernel).unwrap(); + assert_eq!( + kernel.calls(), + vec![Call::Posix, Call::Sleep(20), Call::Posix, Call::Sleep(20), Call::Posix], + "code {code}" + ); + } + } + + #[test] + fn a_destination_held_forever_fails_after_twenty_five_attempts_naming_the_code() { + for code in [5, 32, 33] { + let kernel = FakeKernel::scripted(&[code; 40], &[]); + let error = run(&kernel).unwrap_err().to_string(); + let calls = kernel.calls(); + assert_eq!(calls.iter().filter(|c| **c == Call::Posix).count(), 25); + assert_eq!(calls.iter().filter(|c| **c == Call::Sleep(20)).count(), 25); + assert!(error.contains("25 attempt(s)") && error.contains(&format!("error {code}")), "{error}"); + } + } + + #[test] + fn a_held_destination_after_fallback_exhausts_through_move_file_ex() { + let kernel = FakeKernel::scripted(&[87], &[32; 40]); + assert!(run(&kernel).is_err()); + let calls = kernel.calls(); + assert_eq!(calls.iter().filter(|c| **c == Call::Posix).count(), 1); + assert_eq!(calls.iter().filter(|c| matches!(c, Call::MoveFile(0x9))).count(), 25); + } + + #[test] + fn any_other_code_fails_at_once_without_a_retry() { + for (posix, move_file) in [(&[2u32][..], &[][..]), (&[87][..], &[3][..])] { + let kernel = FakeKernel::scripted(posix, move_file); + let error = run(&kernel).unwrap_err().to_string(); + assert!(!kernel.calls().contains(&Call::Sleep(20)), "{error}"); + assert!(error.contains("1 attempt(s)"), "{error}"); + } + } + + #[test] + fn move_file_ex_answering_unsupported_is_an_error_not_a_cue_to_fall_back_again() { + let kernel = FakeKernel::scripted(&[87], &[32, 87]); + let error = run(&kernel).unwrap_err().to_string(); + assert_eq!( + kernel.calls(), + vec![Call::Posix, Call::MoveFile(0x9), Call::Sleep(20), Call::MoveFile(0x9)], + "{error}" + ); + assert!(error.contains("error 87") && error.contains("2 attempt(s)"), "{error}"); } } @@ -4459,6 +4675,35 @@ mod windows_replace_tests { use super::replace_file_atomically; use std::os::windows::fs::OpenOptionsExt; + const FILE_SHARE_ALL: u32 = 0x1 | 0x2 | 0x4; // READ | WRITE | DELETE + + /// A reader that shares delete holds the old store open. A POSIX-semantics rename + /// replaces the name while the reader keeps the old content; MoveFileExW alone fails + /// there with error 5 (Soul's Starfire probe, build 26200), so this test passes only + /// if the POSIX path is live. Compile-checked only: it has never run on Windows. + #[test] + fn a_replace_succeeds_over_a_delete_sharing_reader_which_keeps_the_old_content() { + use std::io::Read; + let dir = std::env::temp_dir().join(format!("cultcache-posix-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let destination = dir.join("store.cc"); + let staged = dir.join("store.cc.tmp"); + std::fs::write(&destination, b"old").unwrap(); + std::fs::write(&staged, b"new").unwrap(); + let mut reader = std::fs::OpenOptions::new() + .read(true) + .share_mode(FILE_SHARE_ALL) + .open(&destination) + .unwrap(); + replace_file_atomically(&staged, &destination).expect("POSIX rename over a sharing reader"); + let mut seen = Vec::new(); + reader.read_to_end(&mut seen).unwrap(); + assert_eq!(seen, b"old", "the reader keeps the old content"); + assert_eq!(std::fs::read(&destination).unwrap(), b"new"); + drop(reader); + let _ = std::fs::remove_dir_all(&dir); + } + /// A reader holding the destination open with no sharing makes the /// replace fail for as long as it holds on; it must succeed once the /// reader lets go, not report the first refusal as the outcome. @@ -4529,7 +4774,8 @@ fn injected_write_fault(_step: WriteStep) -> std::io::Result<()> { #[cfg(windows)] fn sync_parent_directory(_path: &Path) -> Result<()> { - // MoveFileExW with MOVEFILE_WRITE_THROUGH owns publication durability. + // Publication durability is the replace's: the MoveFileExW fallback writes through; the POSIX-semantics + // rename has no write-through flag and its durability is unmeasured (follow-up windows-replace-run-and-durability). Ok(()) } From 558526c125d6906f8eda881a1dc4a135394357ad Mon Sep 17 00:00:00 2001 From: Libby Date: Fri, 13 Nov 2026 01:53:50 +0000 Subject: [PATCH 72/93] C# and Python: soft flush means one thing (soft-flush-cs-py) (#153) --- packages/cultcache-py/CHANGELOG.md | 1 + .../src/cultcache_py/backing_store.py | 11 +- .../cultcache-py/src/cultcache_py/stores.py | 35 +++- .../cultcache-py/tests/test_soft_push_all.py | 108 +++++++++++ .../DirectoryMessagePackBackingStore.cs | 9 +- src/GameCult.Caching/CultCache.cs | 27 ++- src/GameCult.Mesh/CultMesh.cs | 7 +- src/GameCult.Networking/CultNetLocal.cs | 7 +- .../GameCult.Caching.ConsumerBuild/Program.cs | 2 +- .../OutsideStoreJudgeTests.cs | 2 +- .../BackingStoreTests.cs | 131 +++++++++++++ .../CultCacheJournalTests.cs | 2 +- .../CultCacheObserverTests.cs | 2 +- .../DirectoryStoreDurabilityTests.cs | 66 +++++++ .../StoreRoutingTests.cs | 4 +- .../NetworkingTests.cs | 177 ++++++++++++++++++ unity/org.gamecult.cultlib/CHANGELOG.md | 1 + 17 files changed, 560 insertions(+), 32 deletions(-) create mode 100644 packages/cultcache-py/tests/test_soft_push_all.py diff --git a/packages/cultcache-py/CHANGELOG.md b/packages/cultcache-py/CHANGELOG.md index 665f9ae1..c58178cb 100644 --- a/packages/cultcache-py/CHANGELOG.md +++ b/packages/cultcache-py/CHANGELOG.md @@ -13,6 +13,7 @@ ### Breaking +- `push_all(envelopes, soft=False)` on the single-file and JSONL stores and on the `BackingStore` protocol takes the soft flag, as TypeScript, Rust and C# do: a soft push writes only where nothing is at the path; over a store the reader reads, empty included, it returns without writing, and over a file the reader cannot read it refuses (`StoreUnreadableError` for the single-file store). The `BackingStore` protocol's default `push_all` cannot tell an absent store from an empty one (a pull returns a list), so a soft push through it always skips a store whose pull returns; a store kind that can tell nothing-at-the-path overrides `push_all`. A store kind that overrides `push_all(envelopes)` without the keyword fails when a caller passes `soft`. - A write at a store path that is a link, live or dangling, is refused with the new `StoreLinkedError` (an `OSError`, exported from `cultcache_py`; `.path` names the path) before the lock and the replace, for the single-file store and for `JsonLinesBackingStore` (`push`, `push_all`, `delete`), and nothing is written. Before, a write at a live link replaced the link with a regular file, and at a dangling link both stores raised `FileNotFoundError`. A caller catching `FileNotFoundError` there now catches `StoreLinkedError` (`OSError`); configure the link's target as the store path. On Windows a junction or any reparse point counts as a link. Reads through a live link work. - A record whose payload carries a slot past its reader's declared slots, at any depth (the document, a nested record, a list element, a union arm, a unit arm), is read, held read-only, and every write or removal of its key (`put`, `put_global`, `update`, `put_envelope`, `put_envelopes`, `delete`, `delete_global`) is refused with `SchemaConflictError` before any store is touched (`cultlib-gaps:ruling:extra-trailing-slots`). Reading dropped those slots silently and the next write shed them. `SchemaConflictError.unread_slots` is an `UnreadSlots(type_name, slot_count, declared_count, occurrences, depth0_only)` and the message names the type and both counts, never a slot value. A depth-0 mark is consented, and the record writable, only when the record is stored under an id the document lists in `compatible_schema_ids` and that id's catalog entry has slots past the document's, covering the mark's slot count (`cultlib-gaps:ruling:slot-mark-consent`); a nested mark, or a mark under the document's own id, keeps the record read-only whatever is declared. Missing trailing slots keep their declared defaults. An untouched marked record keeps its stored bytes through every other write. Added `CultCache.is_writable(document, key)` (advisory; the write still decides: it also answers False for a foreign-carried key, for a key held as a type the document does not own, and for a global document at a non-global key; `delete` asks the same judges, so removing a key held as another type is refused with `SchemaConflictError` unless the document owns that record's schema id (its own or one it lists compatible), in which case the removal removes that record, its file row and its lookups, as a write of the key would replace it; it returned silently and removed nothing), `DocumentDefinition.decode_payload_marked`, and `UnreadSlots`. A document with a custom `decode` (no slot codec) never marks. A store holding a variant record is still unreadable here. Implies a minor bump while the version is 0.x (0.3.0 to 0.4.0): code that wrote back a record from a newer writer now gets `SchemaConflictError`. - A payload is one MessagePack value: `DocumentDefinition.decode_payload` refuses bytes after it, by count, with `ValueError` ` payload has N bytes after its first MessagePack value (refused on read).` (never the bytes, never the depth refusal; a payload that ends early stays the decoder's), as the C# reference does (`tests/vectors/union-parity/bytes-after-outcomes.msgpack`). It raised `msgpack.ExtraData` (a `ValueError`, so only a caller naming `msgpack.ExtraData` is affected); this is a minor-version bump while pre-1.0. The refused set is unchanged. Cache loads, `get` and every definition built on the MessagePack decoder go through it; a user `payload_decoder` that raises `ExtraData` is refused the same way. Depth is judged first, so a value nested past 256 with a tail is the depth refusal, as in C#. diff --git a/packages/cultcache-py/src/cultcache_py/backing_store.py b/packages/cultcache-py/src/cultcache_py/backing_store.py index 5a21e496..7b07e7be 100644 --- a/packages/cultcache-py/src/cultcache_py/backing_store.py +++ b/packages/cultcache-py/src/cultcache_py/backing_store.py @@ -67,10 +67,13 @@ class BackingStore(Protocol): def delete(self, type: str, key: str) -> None: ... - def push_all(self, envelopes: list[CultCacheEnvelope]) -> None: - existing = { - (envelope.type, envelope.key): envelope for envelope in self.pull_all() - } + def push_all(self, envelopes: list[CultCacheEnvelope], soft: bool = False) -> None: + # A pull that returns is a store the reader read, empty included: the protocol cannot say "absent", so a soft push + # through this default always skips. A store that can tell nothing-at-the-path overrides push_all. + pulled = self.pull_all() + if soft: + return + existing = {(envelope.type, envelope.key): envelope for envelope in pulled} for envelope in envelopes: existing[(envelope.type, envelope.key)] = envelope self._replace_all(list(existing.values())) diff --git a/packages/cultcache-py/src/cultcache_py/stores.py b/packages/cultcache-py/src/cultcache_py/stores.py index ba4c703d..b950619c 100644 --- a/packages/cultcache-py/src/cultcache_py/stores.py +++ b/packages/cultcache-py/src/cultcache_py/stores.py @@ -76,10 +76,14 @@ class JsonLinesBackingStore: self._lock = threading.RLock() def pull_all(self) -> list[CultCacheEnvelope]: + return self._pull() or [] + + def _pull(self) -> list[CultCacheEnvelope] | None: + """The envelopes the reader reads, or None when nothing is at the path.""" with self._lock: text = _read_store(self.path, lambda path: path.read_text(encoding="utf-8")) if text is None: - return [] + return None envelopes: list[CultCacheEnvelope] = [] for line_number, line in enumerate(text.splitlines(), start=1): if not line.strip(): @@ -103,20 +107,24 @@ class JsonLinesBackingStore: def push(self, envelope: CultCacheEnvelope) -> None: self.push_all([envelope]) - def push_all(self, envelopes: list[CultCacheEnvelope]) -> None: + def push_all(self, envelopes: list[CultCacheEnvelope], soft: bool = False) -> None: def merge(existing: dict[tuple[str, str], CultCacheEnvelope]) -> None: for envelope in envelopes: existing[(envelope.type, envelope.key)] = envelope - self._rewrite(merge) + self._rewrite(merge, soft) def delete(self, type: str, key: str) -> None: self._rewrite(lambda existing: existing.pop((type, key), None)) - def _rewrite(self, change: Callable[[dict[tuple[str, str], CultCacheEnvelope]], object]) -> None: - """The one entry of every write: refuses a link at the path, then reads the file, applies the change and replaces it.""" + def _rewrite(self, change: Callable[[dict[tuple[str, str], CultCacheEnvelope]], object], soft: bool = False) -> None: + """The one entry of every write: refuses a link at the path, then reads the file (a soft write stops here when the + reader read it), applies the change and replaces it.""" with self._lock: _refuse_linked(self.path) - existing = {(item.type, item.key): item for item in self.pull_all()} + arrived = self._pull() + if soft and arrived is not None: + return + existing = {(item.type, item.key): item for item in arrived or []} change(existing) self._replace_all(list(existing.values())) @@ -144,13 +152,17 @@ class SingleFileMessagePackBackingStore: self._format = STORE_FORMAT_VERSION def pull_all(self) -> list[CultCacheEnvelope]: + return self._pull() or [] + + def _pull(self) -> list[CultCacheEnvelope] | None: + """The envelopes the reader reads, or None when nothing is at the path.""" with self._lock: # An empty file is not a store (no writer leaves one), so it is decoded and refused. # The disk decides the header: a file that is gone is not marked. self._format = STORE_FORMAT_VERSION data = _read_store(self.path, Path.read_bytes) if data is None: - return [] + return None try: decoded = msgpack.unpackb(data, raw=False) snapshot = _decode_snapshot(decoded) @@ -165,9 +177,14 @@ class SingleFileMessagePackBackingStore: with self._write_transaction(): self._replace_all([envelope], self.pull_all()) - def push_all(self, envelopes: list[CultCacheEnvelope]) -> None: + def push_all(self, envelopes: list[CultCacheEnvelope], soft: bool = False) -> None: + """A soft push writes only where nothing is at the path: a store the reader reads is left byte for byte, and a file it + cannot read is refused by the same read, as in TS and Rust.""" with self._write_transaction(): - self._replace_all(list(envelopes), self.pull_all()) + arrived = self._pull() + if soft and arrived is not None: + return + self._replace_all(list(envelopes), arrived or []) def delete(self, type: str, key: str) -> None: with self._write_transaction(): diff --git a/packages/cultcache-py/tests/test_soft_push_all.py b/packages/cultcache-py/tests/test_soft_push_all.py new file mode 100644 index 00000000..9a2c6e03 --- /dev/null +++ b/packages/cultcache-py/tests/test_soft_push_all.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import shutil +import tempfile +import unittest +from pathlib import Path + +from cultcache_py import JsonLinesBackingStore, SingleFileMessagePackBackingStore, StoreUnreadableError +from cultcache_py.backing_store import BackingStore, CultCacheEnvelope + + +def envelope(key: str) -> CultCacheEnvelope: + return CultCacheEnvelope(key=key, type="t", payload=b"\x01", stored_at="2026-11-06T00:00:00Z") + + +class SoftPushAllTests(unittest.TestCase): + """A soft push writes where nothing is, skips a store it reads, and refuses one it cannot read (TS and Rust parity).""" + + def setUp(self) -> None: + self.dir = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True) + self.path = self.dir / "state.cc" + + def test_over_a_readable_store_it_leaves_the_file_byte_for_byte(self) -> None: + for make in (SingleFileMessagePackBackingStore, JsonLinesBackingStore): + with self.subTest(make.__name__): + path = self.dir / make.__name__ + store = make(path) + store.push_all([envelope("a")]) + before = path.read_bytes() + store.push_all([envelope("b")], soft=True) + self.assertEqual(before, path.read_bytes()) + self.assertEqual(["a"], [item.key for item in make(path).pull_all()]) + store.push_all([envelope("b")]) # a plain push still lands what the caller holds + self.assertEqual(["a", "b"], sorted(item.key for item in make(path).pull_all())) + + def test_over_a_readable_store_holding_no_records_it_leaves_the_file_byte_for_byte(self) -> None: + for make in (SingleFileMessagePackBackingStore, JsonLinesBackingStore): + with self.subTest(make.__name__): + path = self.dir / make.__name__ + store = make(path) + store.push_all([envelope("a")]) + store.delete("t", "a") + self.assertTrue(path.exists()) + self.assertEqual([], make(path).pull_all()) + before = path.read_bytes() + store.push_all([envelope("b")], soft=True) + self.assertEqual(before, path.read_bytes()) + self.assertEqual([], make(path).pull_all()) + + def test_over_an_absent_path_it_writes(self) -> None: + for make in (SingleFileMessagePackBackingStore, JsonLinesBackingStore): + with self.subTest(make.__name__): + path = self.dir / make.__name__ + make(path).push_all([envelope("a")], soft=True) + self.assertEqual(["a"], [item.key for item in make(path).pull_all()]) + + def test_over_an_unreadable_file_it_refuses_and_leaves_it(self) -> None: + junk = b"\xc1\x01\x02\x03" + self.path.write_bytes(junk) + with self.assertRaises(StoreUnreadableError): + SingleFileMessagePackBackingStore(self.path).push_all([envelope("a")], soft=True) + self.assertEqual(junk, self.path.read_bytes()) + + def test_over_an_unreadable_jsonl_file_it_refuses_and_leaves_it(self) -> None: + junk = b"this is not json\n" + self.path.write_bytes(junk) + with self.assertRaises(ValueError): + JsonLinesBackingStore(self.path).push_all([envelope("a")], soft=True) + self.assertEqual(junk, self.path.read_bytes()) + + def test_the_protocol_default_push_all_skips_what_its_pull_read_empty_included(self) -> None: + class Memory(BackingStore): + def __init__(self) -> None: + self.items: list[CultCacheEnvelope] = [] + + def pull_all(self) -> list[CultCacheEnvelope]: + return list(self.items) + + def _replace_all(self, envelopes: list[CultCacheEnvelope]) -> None: + self.items = list(envelopes) + + store = Memory() + store.push_all([envelope("a")], soft=True) # the pull read an empty store: skips + self.assertEqual([], store.items) + store.push_all([envelope("a")]) # plain: lands + store.push_all([envelope("b")], soft=True) # a store with records: skips + self.assertEqual(["a"], [item.key for item in store.items]) + store.push_all([envelope("b")]) + self.assertEqual(["a", "b"], sorted(item.key for item in store.items)) + + def test_the_protocol_default_push_all_refuses_where_its_pull_refuses(self) -> None: + class Unreadable(BackingStore): + replaced = False + + def pull_all(self) -> list[CultCacheEnvelope]: + raise StoreUnreadableError(Path("x"), ValueError("junk")) + + def _replace_all(self, envelopes: list[CultCacheEnvelope]) -> None: + Unreadable.replaced = True + + with self.assertRaises(StoreUnreadableError): + Unreadable().push_all([envelope("a")], soft=True) + self.assertFalse(Unreadable.replaced) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs index aeec2e21..6f6a4daf 100644 --- a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs +++ b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs @@ -237,7 +237,7 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore }); } - public override void PushAll() + public override void PushAll(bool soft) { ThrowIfReadOnly(); Held(() => @@ -247,7 +247,12 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore Directory.CreateDirectory(_manifestFile.DirectoryName!); using var commitLease = AcquireCommitLease(wait: true, create: true); - var (manifest, catalogBytes) = ReadManifest(); + var manifestBytes = ReadManifestBytes(); + var (manifest, catalogBytes) = ParseManifest(manifestBytes); + // A soft flush writes only where no manifest is: the reader's answer (null is absent), not a second look at the path. + // A manifest it read stays as it is, its staged writes staged. + if (soft && manifestBytes != null) + return; RefuseUndeclaredOnDisk(manifest, StagedKeys, Array.Empty(), Array.Empty(), Array.Empty()); if (IsDirty) WriteGeneration(manifest, catalogBytes); diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index 76010354..3af8ba70 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -2537,19 +2537,24 @@ namespace GameCult.Caching return Task.CompletedTask; } - public void FlushAllBackingStores() => Held(() => + public void FlushAllBackingStores(bool soft = false) => Held(() => { foreach (var (store, _) in _stores) { if (!store.IsReadOnly && (store.IsDirty || store.HasStagedChecks)) - store.PushAll(); + store.PushAll(soft); } return true; }); - public Task FlushAsync() + /// + /// Flushes every dirty backing store. With the flush has one meaning, decided by each store's + /// own read: a store with nothing at its path is written; a store that reads (empty included) is skipped and its + /// staged writes stay staged; a store that cannot be read refuses with . + /// + public Task FlushAsync(bool soft = false) { - FlushAllBackingStores(); + FlushAllBackingStores(soft); return Task.CompletedTask; } @@ -3945,7 +3950,13 @@ namespace GameCult.Caching public abstract void PullAll(); public abstract void Push(CultStoredDocument entry); public abstract void Delete(CultStoredDocument entry); - public abstract void PushAll(); + public void PushAll() => PushAll(soft: false); + + /// + /// Applies the staged writes. A soft flush writes only where nothing is at the path: over a store it reads it returns without + /// writing and leaves the staged writes staged and dirty for a later plain flush, and over a file it cannot read it refuses. + /// + public abstract void PushAll(bool soft); /// /// One durable step under the store's exclusive lock: the batch and every staged write land onto the store as it is then, @@ -4184,7 +4195,7 @@ namespace GameCult.Caching /// /// /// A writable store kind that reads its durable state under a lock overrides this to call , and - /// judges at its next or as it judges + /// judges at its next or as it judges /// : under the lock, with the keys it writes, refused through the same refusal, never /// removed. It calls when that step succeeds, which clears them. A read-only store has no /// such step and keeps the default. Both shipped store kinds do this. A batch's own checks are judged through @@ -4661,7 +4672,7 @@ namespace GameCult.Caching // only per staged key. A key whose durable record is under an id this cache does not declare refuses the write, and in a store // holding a variant it lands only onto the store as this cache last read it (both ApplyWriteSet). // A file it cannot read (truncated, trailing bytes, not a store, a format it does not read) is refused and left as it is. - public override void PushAll() + public override void PushAll(bool soft) { ThrowIfReadOnly(); Held(() => @@ -4669,6 +4680,8 @@ namespace GameCult.Caching using (AcquireLock(wait: true)) { var disk = ReadSnapshot(); + if (soft && disk != null) + return; var (upserts, removals) = StagedWrites(Array.Empty(), Array.Empty()); Wrote(ApplyWriteSet(disk, upserts, removals, Array.Empty())); } diff --git a/src/GameCult.Mesh/CultMesh.cs b/src/GameCult.Mesh/CultMesh.cs index e3f23430..32b45e1f 100644 --- a/src/GameCult.Mesh/CultMesh.cs +++ b/src/GameCult.Mesh/CultMesh.cs @@ -428,11 +428,14 @@ namespace GameCult.Mesh } /// - /// Flushes the durable cache. + /// Flushes the durable cache. With the flush has one meaning, decided by the store's own + /// read and never by what the cache holds in memory: a store with nothing at its path is written; a store that reads + /// (empty included) is skipped and its staged writes stay staged; a store that cannot be read refuses with + /// and is left untouched. /// public Task FlushAsync(bool soft = false) { - return _host.FlushAsync(); + return _host.FlushAsync(soft); } /// diff --git a/src/GameCult.Networking/CultNetLocal.cs b/src/GameCult.Networking/CultNetLocal.cs index 87942ce8..60937574 100644 --- a/src/GameCult.Networking/CultNetLocal.cs +++ b/src/GameCult.Networking/CultNetLocal.cs @@ -96,11 +96,14 @@ namespace GameCult.Networking } /// - /// Flushes the durable cache. + /// Flushes the durable cache. With the flush has one meaning, decided by the store's own + /// read and never by what the cache holds in memory: a store with nothing at its path is written; a store that reads + /// (empty included) is skipped and its staged writes stay staged; a store that cannot be read refuses with + /// and is left untouched. /// public Task FlushAsync(bool soft = false) { - return Cache.FlushAsync(); + return Cache.FlushAsync(soft); } /// diff --git a/tests/GameCult.Caching.ConsumerBuild/Program.cs b/tests/GameCult.Caching.ConsumerBuild/Program.cs index 60376068..f09edb8c 100644 --- a/tests/GameCult.Caching.ConsumerBuild/Program.cs +++ b/tests/GameCult.Caching.ConsumerBuild/Program.cs @@ -56,7 +56,7 @@ public sealed class OutsideStore : CacheBackingStore public override void Delete(CultStoredDocument entry) { } - public override void PushAll() { } + public override void PushAll(bool soft) { } public override CultCommitOutcome CommitBatch(CultCommitRequest request, bool wait) => request.ConditionsHold(new Dictionary { ["w"] = "v1" }, Entries.Values, ForeignRecords) diff --git a/tests/GameCult.Caching.OutsideStoreTests/OutsideStoreJudgeTests.cs b/tests/GameCult.Caching.OutsideStoreTests/OutsideStoreJudgeTests.cs index e47e4d5f..bd2fe047 100644 --- a/tests/GameCult.Caching.OutsideStoreTests/OutsideStoreJudgeTests.cs +++ b/tests/GameCult.Caching.OutsideStoreTests/OutsideStoreJudgeTests.cs @@ -136,7 +136,7 @@ namespace GameCult.Caching.OutsideStoreTests IsDirty = true; }); - public override void PushAll() => Held(() => Apply(Array.Empty(), Array.Empty(), Array.Empty())); + public override void PushAll(bool soft) => Held(() => Apply(Array.Empty(), Array.Empty(), Array.Empty())); public override CultCommitOutcome CommitBatch(CultCommitRequest request, bool wait) => Held(() => { diff --git a/tests/GameCult.Caching.Tests/BackingStoreTests.cs b/tests/GameCult.Caching.Tests/BackingStoreTests.cs index 7e88c283..8d5c3424 100644 --- a/tests/GameCult.Caching.Tests/BackingStoreTests.cs +++ b/tests/GameCult.Caching.Tests/BackingStoreTests.cs @@ -56,6 +56,137 @@ namespace GameCult.Caching.Tests } } + [Test] + public async Task SoftFlush_Over_Readable_Store_Leaves_It_Byte_For_Byte_And_Keeps_Staged_Writes_Dirty() + { + var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.msgpack"); + try + { + var store = new SingleFileMessagePackBackingStore(filePath); + var cache = new CultCache(); + cache.AddBackingStore(store); + await cache.AddAsync(new NamedTestEntry { Name = "first", Value = "one" }); + await cache.FlushAsync(); + var before = File.ReadAllBytes(filePath); + + var staged = await cache.AddAsync(new NamedTestEntry { Name = "second", Value = "two" }); + await cache.FlushAsync(soft: true); + + Assert.That(File.ReadAllBytes(filePath), Is.EqualTo(before), "a soft flush rewrote a store it reads"); + Assert.That(store.IsDirty, Is.True, "a skipped soft flush discarded a staged write"); + + await cache.FlushAsync(); + var reader = new CultCache(); + reader.AddBackingStore(new SingleFileMessagePackBackingStore(filePath)); + await reader.PullAllBackingStoresAsync(); + Assert.That(reader.Get(staged.Key)!.Value, Is.EqualTo("two")); + } + finally + { + if (File.Exists(filePath)) File.Delete(filePath); + } + } + + [Test] + public async Task SoftFlush_Over_Readable_Store_Holding_No_Records_Leaves_It() + { + var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.msgpack"); + try + { + var store = new SingleFileMessagePackBackingStore(filePath); + var cache = new CultCache(); + cache.AddBackingStore(store); + var first = await cache.AddAsync(new NamedTestEntry { Name = "first", Value = "one" }); + await cache.FlushAsync(); + cache.Remove(first.Key); + await cache.FlushAsync(); + var before = File.ReadAllBytes(filePath); + var empty = new CultCache(); + empty.AddBackingStore(new SingleFileMessagePackBackingStore(filePath)); + Assert.That(empty.AllEntries, Is.Empty, "the store is not readable-empty"); + + await cache.AddAsync(new NamedTestEntry { Name = "second", Value = "two" }); + await cache.FlushAsync(soft: true); + + Assert.That(File.ReadAllBytes(filePath), Is.EqualTo(before), "a soft flush wrote over a readable store that holds no records"); + Assert.That(store.IsDirty, Is.True); + } + finally + { + if (File.Exists(filePath)) File.Delete(filePath); + } + } + + [Test] + public async Task PlainPushAll_Over_Readable_Store_Writes_The_Staged_Records() + { + var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.msgpack"); + try + { + var store = new SingleFileMessagePackBackingStore(filePath); + var cache = new CultCache(); + cache.AddBackingStore(store); + await cache.AddAsync(new NamedTestEntry { Name = "first", Value = "one" }); + store.PushAll(); + var before = File.ReadAllBytes(filePath); + + await cache.AddAsync(new NamedTestEntry { Name = "second", Value = "two" }); + store.PushAll(); + + Assert.That(File.ReadAllBytes(filePath), Is.Not.EqualTo(before), "a plain PushAll skipped a store it reads"); + Assert.That(store.IsDirty, Is.False); + } + finally + { + if (File.Exists(filePath)) File.Delete(filePath); + } + } + + [Test] + public async Task SoftFlush_Over_Absent_Path_Writes() + { + var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.msgpack"); + try + { + var store = new SingleFileMessagePackBackingStore(filePath); + var cache = new CultCache(); + cache.AddBackingStore(store); + await cache.AddAsync(new NamedTestEntry { Name = "first", Value = "one" }); + + await cache.FlushAsync(soft: true); + + Assert.That(File.Exists(filePath), Is.True); + Assert.That(store.IsDirty, Is.False); + } + finally + { + if (File.Exists(filePath)) File.Delete(filePath); + } + } + + [Test] + public async Task SoftFlush_Over_Unreadable_File_Refuses_And_Leaves_It() + { + var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.msgpack"); + try + { + var junk = new byte[] { 0xC1, 0x01, 0x02, 0x03 }; + var store = new SingleFileMessagePackBackingStore(filePath); + var cache = new CultCache(); + cache.AddBackingStore(store); + await cache.AddAsync(new NamedTestEntry { Name = "first", Value = "one" }); + File.WriteAllBytes(filePath, junk); + + Assert.That(() => store.PushAll(soft: true), Throws.InstanceOf()); + Assert.That(File.ReadAllBytes(filePath), Is.EqualTo(junk)); + Assert.That(store.IsDirty, Is.True); + } + finally + { + if (File.Exists(filePath)) File.Delete(filePath); + } + } + [Test] public async Task SingleFileMessagePackBackingStore_PullAll_SeesFileCreatedAfterReaderOpened() { diff --git a/tests/GameCult.Caching.Tests/CultCacheJournalTests.cs b/tests/GameCult.Caching.Tests/CultCacheJournalTests.cs index 404a75cb..f4009a6c 100644 --- a/tests/GameCult.Caching.Tests/CultCacheJournalTests.cs +++ b/tests/GameCult.Caching.Tests/CultCacheJournalTests.cs @@ -338,7 +338,7 @@ namespace GameCult.Caching.Tests public override void Push(CultStoredDocument entry) { } public override void Delete(CultStoredDocument entry) { } - public override void PushAll() { } + public override void PushAll(bool soft) { } public override CultCommitOutcome CommitBatch(CultCommitRequest request, bool wait) => CultCommitOutcome.Committed; } diff --git a/tests/GameCult.Caching.Tests/CultCacheObserverTests.cs b/tests/GameCult.Caching.Tests/CultCacheObserverTests.cs index b2c8e7f5..92861214 100644 --- a/tests/GameCult.Caching.Tests/CultCacheObserverTests.cs +++ b/tests/GameCult.Caching.Tests/CultCacheObserverTests.cs @@ -180,7 +180,7 @@ namespace GameCult.Caching.Tests public override void Push(CultStoredDocument entry) { } public override void Delete(CultStoredDocument entry) { } - public override void PushAll() { } + public override void PushAll(bool soft) { } public override CultCommitOutcome CommitBatch(CultCommitRequest request, bool wait) => CultCommitOutcome.Committed; } diff --git a/tests/GameCult.Caching.Tests/DirectoryStoreDurabilityTests.cs b/tests/GameCult.Caching.Tests/DirectoryStoreDurabilityTests.cs index 840110bc..942ae891 100644 --- a/tests/GameCult.Caching.Tests/DirectoryStoreDurabilityTests.cs +++ b/tests/GameCult.Caching.Tests/DirectoryStoreDurabilityTests.cs @@ -208,6 +208,72 @@ namespace GameCult.Caching.Tests Assert.That(reader.AllEntries, Is.Empty); } + [Test] + public async Task SoftFlushOverAbsentManifestWrites() + { + using var cache = Open(out var store); + var key = await cache.UpsertAsync(typeof(Page), new Page { Name = "a", Text = "one" }); + + cache.FlushAllBackingStores(soft: true); + + Assert.That(File.Exists(_manifest), Is.True); + Assert.That(store.IsDirty, Is.False); + using var reader = Open(out _); + Assert.That(reader.Get(key)?.Text, Is.EqualTo("one")); + } + + [Test] + public async Task SoftFlushOverReadableManifestLeavesItAndKeepsStagedWritesDirty() + { + using var cache = Open(out var store); + await cache.UpsertAsync(typeof(Page), new Page { Name = "a", Text = "one" }); + cache.FlushAllBackingStores(); + var before = File.ReadAllBytes(_manifest); + var pages = Directory.GetFiles(_records, "*.msgpack").OrderBy(path => path).ToArray(); + var second = await cache.UpsertAsync(typeof(Page), new Page { Name = "b", Text = "two" }); + + cache.FlushAllBackingStores(soft: true); + + Assert.That(File.ReadAllBytes(_manifest), Is.EqualTo(before), "a soft flush rewrote a manifest it reads"); + Assert.That(Directory.GetFiles(_records, "*.msgpack").OrderBy(path => path).ToArray(), Is.EqualTo(pages)); + Assert.That(store.IsDirty, Is.True, "a skipped soft flush discarded a staged write"); + + cache.FlushAllBackingStores(); + using var reader = Open(out _); + Assert.That(reader.Get(second)?.Text, Is.EqualTo("two")); + } + + [Test] + public async Task SoftFlushOverReadableEmptyManifestLeavesIt() + { + using var cache = Open(out var store); + var key = await cache.UpsertAsync(typeof(Page), new Page { Name = "a", Text = "one" }); + cache.FlushAllBackingStores(); + cache.Remove(key); + cache.FlushAllBackingStores(); + var before = File.ReadAllBytes(_manifest); + using (var reader = Open(out _)) Assert.That(reader.AllEntries, Is.Empty, "the store is not readable-empty"); + await cache.UpsertAsync(typeof(Page), new Page { Name = "b", Text = "two" }); + + cache.FlushAllBackingStores(soft: true); + + Assert.That(File.ReadAllBytes(_manifest), Is.EqualTo(before), "a soft flush wrote over a readable store that holds no records"); + Assert.That(store.IsDirty, Is.True); + } + + [Test] + public async Task SoftFlushOverUnreadableManifestRefusesAndLeavesIt() + { + using var cache = Open(out var store); + await cache.UpsertAsync(typeof(Page), new Page { Name = "a", Text = "one" }); + var junk = new byte[] { 0xC1, 0x01, 0x02, 0x03 }; + File.WriteAllBytes(_manifest, junk); + + Assert.That(() => store.PushAll(soft: true), Throws.Exception); + Assert.That(File.ReadAllBytes(_manifest), Is.EqualTo(junk)); + Assert.That(store.IsDirty, Is.True); + } + private CultCache Open(out DirectoryMessagePackBackingStore store) { var cache = new CultCache(Registry); diff --git a/tests/GameCult.Caching.Tests/StoreRoutingTests.cs b/tests/GameCult.Caching.Tests/StoreRoutingTests.cs index af7e7da7..3ccd7a04 100644 --- a/tests/GameCult.Caching.Tests/StoreRoutingTests.cs +++ b/tests/GameCult.Caching.Tests/StoreRoutingTests.cs @@ -699,10 +699,10 @@ namespace GameCult.Caching.Tests _nested = nested; } - public override void PushAll() + public override void PushAll(bool soft) { _nested(); - base.PushAll(); + base.PushAll(soft); } } diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs index 606d5625..1aee870d 100644 --- a/tests/GameCult.Networking.Tests/NetworkingTests.cs +++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs @@ -7377,6 +7377,183 @@ namespace GameCult.Networking.Tests Assert.That(developmentServer.LogSensitivePayloads, Is.True); } + // Records the flag each PushAll took, and holds nothing: a flush that decided soft itself and never asked the store records nothing. + private sealed class SoftFlagSpyStore : CacheBackingStore + { + public sealed class SpyHome { } + + public readonly List Flags = new(); + public void Stage() => IsDirty = true; + public override void PullAll() { } + public override void Push(CultStoredDocument entry) { } + public override void Delete(CultStoredDocument entry) { } + public override void PushAll(bool soft) => Flags.Add(soft); + public override CultCommitOutcome CommitBatch(CultCommitRequest request, bool wait) => CultCommitOutcome.Committed; + } + + [Test] + public async Task CultNetLocal_Host_FlushAsync_Hands_The_Store_The_Flag_It_Was_Given() + { + var filePath = Path.Combine(Path.GetTempPath(), $"cultnet-host-spy-{Guid.NewGuid():N}.msgpack"); + CultNetHost? host = null; + try + { + host = await CultNetLocal.CreateHostAsync(filePath, new CultNetHostOptions { StartServer = false }); + var spy = new SoftFlagSpyStore(); + host.Cache.AddBackingStore(spy, typeof(SoftFlagSpyStore.SpyHome)); + spy.Stage(); + + await host.FlushAsync(soft: true); + await host.FlushAsync(); + + Assert.That(spy.Flags, Is.EqualTo(new[] { true, false })); + } + finally + { + host?.Dispose(); + if (File.Exists(filePath)) File.Delete(filePath); + } + } + + [Test] + public async Task CultMeshNode_FlushAsync_Hands_The_Store_The_Flag_It_Was_Given() + { + var rootPath = Path.Combine(TestContext.CurrentContext.WorkDirectory, "cultmesh-soft-spy", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(rootPath); + var documents = new CultNetDocumentRegistry() + .Register(CultNetDocumentBinding.ForDocument()); + using var node = await CultMesh.CreateNodeAsync(Path.Combine(rootPath, "world.ccmp"), new CultMeshNodeOptions + { + StartServer = false, + DatabaseOptions = new CultNetDatabaseOptions { RuntimeId = "soft-spy-local", DocumentRegistry = documents } + }); + var spy = new SoftFlagSpyStore(); + node.Cache.AddBackingStore(spy, typeof(SoftFlagSpyStore.SpyHome)); + spy.Stage(); + + await node.FlushAsync(soft: true); + await node.FlushAsync(); + + Assert.That(spy.Flags, Is.EqualTo(new[] { true, false })); + } + + [Test] + public async Task CultNetLocal_Host_SoftFlush_Passes_The_Flag_To_The_Store() + { + var filePath = Path.Combine(Path.GetTempPath(), $"cultnet-host-soft-{Guid.NewGuid():N}.msgpack"); + CultNetHost? host = null; + + try + { + host = await CultNetLocal.CreateHostAsync(filePath, new CultNetHostOptions { StartServer = false }); + await host.Cache.UpsertAsync(new PlayerData { PlayerId = Guid.NewGuid(), Email = "a@example.test", PasswordHash = "hash", Username = "First" }); + await host.FlushAsync(); + var before = File.ReadAllBytes(filePath); + + await host.Cache.UpsertAsync(new PlayerData { PlayerId = Guid.NewGuid(), Email = "b@example.test", PasswordHash = "hash", Username = "Second" }); + await host.FlushAsync(soft: true); + Assert.That(File.ReadAllBytes(filePath), Is.EqualTo(before), "a soft host flush rewrote a store it reads"); + + await host.FlushAsync(); + Assert.That(File.ReadAllBytes(filePath), Is.Not.EqualTo(before), "a plain host flush after a skipped soft flush did not land the staged write"); + } + finally + { + host?.Dispose(); + if (File.Exists(filePath)) File.Delete(filePath); + } + } + + [Test] + public async Task CultMeshNode_SoftFlush_Passes_The_Flag_To_The_Store() + { + var rootPath = Path.Combine(TestContext.CurrentContext.WorkDirectory, "cultmesh-soft-flush", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(rootPath); + var cachePath = Path.Combine(rootPath, "world.ccmp"); + var documents = new CultNetDocumentRegistry() + .Register(CultNetDocumentBinding.ForDocument()); + + using var node = await CultMesh.CreateNodeAsync(cachePath, new CultMeshNodeOptions + { + StartServer = false, + DatabaseOptions = new CultNetDatabaseOptions { RuntimeId = "soft-flush-local", DocumentRegistry = documents } + }); + await node.Database.PutAsync(new CultRecordKey("note:one"), new MeshQuickstartNote { NoteId = "note:one", Body = "one" }); + await node.FlushAsync(); + var before = File.ReadAllBytes(cachePath); + + await node.Database.PutAsync(new CultRecordKey("note:two"), new MeshQuickstartNote { NoteId = "note:two", Body = "two" }); + await node.FlushAsync(soft: true); + Assert.That(File.ReadAllBytes(cachePath), Is.EqualTo(before), "a soft node flush rewrote a store it reads"); + + await node.FlushAsync(); + Assert.That(File.ReadAllBytes(cachePath), Is.Not.EqualTo(before), "a plain node flush after a skipped soft flush did not land the staged write"); + } + + // The cache HOLDS a record in every step: a passthrough that decides soft from the cache's memory instead of + // handing the store the flag fails the absent step (it skips and writes nothing) or the unreadable step (it + // never reaches the reader that refuses). + [Test] + public async Task CultNetLocal_Host_SoftFlush_With_A_Held_Record_Writes_Absent_Skips_Readable_Refuses_Unreadable() + { + var filePath = Path.Combine(Path.GetTempPath(), $"cultnet-host-soft-held-{Guid.NewGuid():N}.msgpack"); + CultNetHost? host = null; + try + { + host = await CultNetLocal.CreateHostAsync(filePath, new CultNetHostOptions { StartServer = false }); + await host.Cache.UpsertAsync(new PlayerData { PlayerId = Guid.NewGuid(), Email = "a@example.test", PasswordHash = "hash", Username = "First" }); + Assert.That(File.Exists(filePath), Is.False); + + await host.FlushAsync(soft: true); + Assert.That(File.Exists(filePath), Is.True, "a soft host flush over an absent store, with a record held, wrote nothing"); + var written = File.ReadAllBytes(filePath); + + await host.Cache.UpsertAsync(new PlayerData { PlayerId = Guid.NewGuid(), Email = "b@example.test", PasswordHash = "hash", Username = "Second" }); + await host.FlushAsync(soft: true); + Assert.That(File.ReadAllBytes(filePath), Is.EqualTo(written), "a soft host flush rewrote a readable store"); + + var junk = new byte[] { 0xC1, 0x01, 0x02, 0x03 }; + File.WriteAllBytes(filePath, junk); + Assert.That(async () => await host.FlushAsync(soft: true), Throws.InstanceOf()); + Assert.That(File.ReadAllBytes(filePath), Is.EqualTo(junk)); + } + finally + { + host?.Dispose(); + if (File.Exists(filePath)) File.Delete(filePath); + } + } + + [Test] + public async Task CultMeshNode_SoftFlush_With_A_Held_Record_Writes_Absent_Skips_Readable_Refuses_Unreadable() + { + var rootPath = Path.Combine(TestContext.CurrentContext.WorkDirectory, "cultmesh-soft-held", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(rootPath); + var cachePath = Path.Combine(rootPath, "world.ccmp"); + var documents = new CultNetDocumentRegistry() + .Register(CultNetDocumentBinding.ForDocument()); + using var node = await CultMesh.CreateNodeAsync(cachePath, new CultMeshNodeOptions + { + StartServer = false, + DatabaseOptions = new CultNetDatabaseOptions { RuntimeId = "soft-held-local", DocumentRegistry = documents } + }); + await node.Database.PutAsync(new CultRecordKey("note:one"), new MeshQuickstartNote { NoteId = "note:one", Body = "one" }); + Assert.That(File.Exists(cachePath), Is.False); + + await node.FlushAsync(soft: true); + Assert.That(File.Exists(cachePath), Is.True, "a soft node flush over an absent store, with a record held, wrote nothing"); + var written = File.ReadAllBytes(cachePath); + + await node.Database.PutAsync(new CultRecordKey("note:two"), new MeshQuickstartNote { NoteId = "note:two", Body = "two" }); + await node.FlushAsync(soft: true); + Assert.That(File.ReadAllBytes(cachePath), Is.EqualTo(written), "a soft node flush rewrote a readable store"); + + var junk = new byte[] { 0xC1, 0x01, 0x02, 0x03 }; + File.WriteAllBytes(cachePath, junk); + Assert.That(async () => await node.FlushAsync(soft: true), Throws.InstanceOf()); + Assert.That(File.ReadAllBytes(cachePath), Is.EqualTo(junk)); + } + [Test] public async Task CultNetLocal_CreateHostAsync_Wires_Durable_Cache_Without_Starting_Server() { diff --git a/unity/org.gamecult.cultlib/CHANGELOG.md b/unity/org.gamecult.cultlib/CHANGELOG.md index a4a2bc11..612aee04 100644 --- a/unity/org.gamecult.cultlib/CHANGELOG.md +++ b/unity/org.gamecult.cultlib/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this package are documented in this file. ### Breaking +- `CacheBackingStore.PushAll()` is no longer abstract: it is a non-virtual convenience over the new abstract `PushAll(bool soft)`. A store kind that overrides `PushAll()` no longer compiles; override `PushAll(bool soft)` instead. A soft flush writes only where nothing is at the path: over a store the reader reads, empty included, it returns without writing and leaves the staged writes staged and dirty for a later plain flush; over a file the reader cannot read it refuses. `CultCache.FlushAsync(bool soft = false)`, `CultCache.FlushAllBackingStores(bool soft = false)`, `CultMeshNode.FlushAsync(soft)` and `CultNetHost.FlushAsync(soft)` pass the flag to the stores; `soft: true` was accepted and ignored before, so a caller that passed it now stops landing writes on an existing store. Semver: breaking for C# consumers with their own store kind. - A document payload with bytes after its first MessagePack value is refused on read and on write (`MessagePackSerializationException` naming the type and the count of bytes after the value, never the bytes); the tail is not walked, so a tail nested past the limit is refused as bytes after the value, not as depth. Before, the tail was ignored on read and dropped on the next write. A payload that ends early is still the deserializer's to refuse. A CDN manifest is a record payload and follows the same rule: the acceptance vectors `trailing_nil`, `trailing_reserved_code` and `trailing_truncated_array` in `contracts/cultmesh/content-acceptance-vectors.json` are now refusals. `CultMeshProviderSessionWire.DecodeCommandDocument` refuses a command record whose payload has bytes after its first value (`MessagePackSerializationException` with the count); before, the tail was ignored. Semver: breaking for C# consumers, a major bump. - A single-file store holding two records under one key is unreadable: the reader refuses it with `CultStoreUnreadableException` naming the key (keys compare by ordinal code units, no case folding or normalisation), so a cache, `ReadStoreSnapshot` and a flush refuse the file and leave it as it is. Before, the later record silently replaced the earlier. A directory store reads its manifest through the same reader and refuses a duplicate key the same way. Repair the duplicate with the runtime that wrote it before reading the store here. - A record whose payload carries a slot past the slots its reading type declares, at any depth (the record, a nested object, a list element, a union arm or a unit arm), is loaded read-only: it is served, and every staged write or removal of its key (`Upsert`, `UpsertVariant`, `Remove`, a batch, and `Flatten` of a variant) is refused with `CultSchemaConflictException` before anything is staged, and the store keeps the record byte for byte. Before, the extra slots were skipped on read and dropped on the next write. Declaring an id compatible consents only to depth-0 slots the schema comparison reports ignored under that id; any other extra slot keeps the record read-only whatever is declared, including under the reader's own id. A variant overriding a slot its type does not have is read-only too (it used to be shed on flush). Write such a record from the build that declares those slots. Missing trailing slots still default and stay writable. From adbbb780fd1a984fecdab13309d1cd7214ef3726 Mon Sep 17 00:00:00 2001 From: Libby Date: Fri, 13 Nov 2026 02:53:48 +0000 Subject: [PATCH 73/93] Python: JSONL store writes through commit_store_file (jsonl-tmp-leak-on-replace-failure) (#154) --- packages/cultcache-py/CHANGELOG.md | 1 + packages/cultcache-py/src/cultcache_py/stores.py | 4 +--- packages/cultcache-py/tests/test_store_replace.py | 13 +++++++++++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/cultcache-py/CHANGELOG.md b/packages/cultcache-py/CHANGELOG.md index c58178cb..c222eb82 100644 --- a/packages/cultcache-py/CHANGELOG.md +++ b/packages/cultcache-py/CHANGELOG.md @@ -9,6 +9,7 @@ ### Changed - `msgpack` is a hard dependency, as `pyproject.toml` declared it. The optional-import fallbacks and the `cultcache-py[msgpack]` extra they named (which never existed) are gone: a missing msgpack is an `ImportError` at import. +- `JsonLinesBackingStore` writes through the same `commit_store_file` as the single-file store: a unique staged file, fsynced before the replace and removed when the replace fails, so a refused replace no longer leaves `.tmp` behind. It staged a fixed `.tmp` without an fsync. - The single-file store's `push`, `push_all` and `delete` take the cross-process lock on `.lock` (flock; LockFileEx over the whole file on Windows) across the read and the replace, waiting up to `lock_timeout` seconds (30 by default, `TimeoutError` after), and exclude the C#, Rust and TypeScript runtimes' writers. The staged file is `.tmp--`, fsynced before the replace, with a directory fsync after it off Windows. A fixed `.tmp` is no longer used. ### Breaking diff --git a/packages/cultcache-py/src/cultcache_py/stores.py b/packages/cultcache-py/src/cultcache_py/stores.py index b950619c..36729d44 100644 --- a/packages/cultcache-py/src/cultcache_py/stores.py +++ b/packages/cultcache-py/src/cultcache_py/stores.py @@ -130,7 +130,6 @@ class JsonLinesBackingStore: def _replace_all(self, envelopes: list[CultCacheEnvelope]) -> None: self.path.parent.mkdir(parents=True, exist_ok=True) - temp = self.path.with_suffix(self.path.suffix + ".tmp") lines = [] for envelope in sorted(envelopes, key=lambda item: (item.type, item.key)): lines.append(json.dumps({ @@ -139,8 +138,7 @@ class JsonLinesBackingStore: "payload": base64.b64encode(envelope.payload).decode("ascii"), "stored_at": envelope.stored_at, }, ensure_ascii=False, sort_keys=True)) - temp.write_text("\n".join(lines) + ("\n" if lines else ""), encoding="utf-8") - _replace_store(temp, self.path) + commit_store_file(self.path, ("\n".join(lines) + ("\n" if lines else "")).encode("utf-8")) class SingleFileMessagePackBackingStore: diff --git a/packages/cultcache-py/tests/test_store_replace.py b/packages/cultcache-py/tests/test_store_replace.py index 120fdc60..af8a2595 100644 --- a/packages/cultcache-py/tests/test_store_replace.py +++ b/packages/cultcache-py/tests/test_store_replace.py @@ -107,6 +107,19 @@ class StoreWritesRouteThroughTheReplace(unittest.TestCase): self.assertEqual([destination for _, destination in seen], [binary, lines]) self.assertTrue(binary.read_bytes() and lines.read_text()) + def test_a_failed_jsonl_replace_leaves_no_staged_file_and_the_old_store(self): + envelope = CultCacheEnvelope(key="k", type="t", payload=b"\x01", stored_at="2026-11-06T00:00:00Z") + with tempfile.TemporaryDirectory() as directory: + lines = Path(directory) / "s.jsonl" + store = stores.JsonLinesBackingStore(lines) + store.push_all([envelope]) + before = lines.read_bytes() + with mock.patch.object(stores, "_replace_store", side_effect=OSError("replace refused")): + with self.assertRaises(OSError): + store.push_all([CultCacheEnvelope(key="k2", type="t", payload=b"\x02", stored_at="2026-11-06T00:00:00Z")]) + self.assertEqual(lines.read_bytes(), before) + self.assertEqual([p.name for p in Path(directory).iterdir() if ".tmp" in p.name], []) + @unittest.skipUnless(sys.platform == "win32", "needs a real Windows") def test_real_kernel_replaces_through_both_primitives(self): kernel = stores.WindowsReplaceKernel() From b646ba273216788ca4799f09692a6b649e83a54d Mon Sep 17 00:00:00 2001 From: Libby Date: Fri, 13 Nov 2026 04:10:35 +0000 Subject: [PATCH 74/93] IsWritable is per record: a link at a store path is the write's to refuse (link-is-writable-query) (#155) --- packages/cultcache-py/CHANGELOG.md | 2 ++ .../cultcache-py/src/cultcache_py/cache.py | 3 +- packages/cultcache-py/tests/test_cultcache.py | 26 ++++++++++++++++ packages/cultcache-rs/CHANGELOG.md | 4 +++ packages/cultcache-rs/src/lib.rs | 31 ++++++++++++++++++- packages/cultcache-ts/CHANGELOG.md | 4 +++ packages/cultcache-ts/src/cult-cache.ts | 3 +- packages/cultcache-ts/test/cult-cache.test.ts | 24 ++++++++++++++ src/GameCult.Caching/CultCache.cs | 3 +- .../StoreLinkRefusalTests.cs | 20 ++++++++++++ 10 files changed, 116 insertions(+), 4 deletions(-) diff --git a/packages/cultcache-py/CHANGELOG.md b/packages/cultcache-py/CHANGELOG.md index c222eb82..e5a8b8e8 100644 --- a/packages/cultcache-py/CHANGELOG.md +++ b/packages/cultcache-py/CHANGELOG.md @@ -8,6 +8,8 @@ ### Changed +- `CultCache.is_writable` does not look at store paths: a link at a store path is not predicted, and the write refuses it with `StoreLinkedError`. Pinned by a test through a cache attached to a linked store. +- `JsonLinesBackingStore` writes LF line endings on Windows. It wrote CRLF there, through `write_text` newline translation, before the commit_store_file change (#154, adbbb780). - `msgpack` is a hard dependency, as `pyproject.toml` declared it. The optional-import fallbacks and the `cultcache-py[msgpack]` extra they named (which never existed) are gone: a missing msgpack is an `ImportError` at import. - `JsonLinesBackingStore` writes through the same `commit_store_file` as the single-file store: a unique staged file, fsynced before the replace and removed when the replace fails, so a refused replace no longer leaves `.tmp` behind. It staged a fixed `.tmp` without an fsync. - The single-file store's `push`, `push_all` and `delete` take the cross-process lock on `.lock` (flock; LockFileEx over the whole file on Windows) across the read and the replace, waiting up to `lock_timeout` seconds (30 by default, `TimeoutError` after), and exclude the C#, Rust and TypeScript runtimes' writers. The staged file is `.tmp--`, fsynced before the replace, with a directory fsync after it off Windows. A fixed `.tmp` is no longer used. diff --git a/packages/cultcache-py/src/cultcache_py/cache.py b/packages/cultcache-py/src/cultcache_py/cache.py index 85b01d89..80a12706 100644 --- a/packages/cultcache-py/src/cultcache_py/cache.py +++ b/packages/cultcache-py/src/cultcache_py/cache.py @@ -636,7 +636,8 @@ class CultCache: a record held read-only (its payload carried slots past the declared ones, at any depth, and nothing consents to the loss, so writing it would drop data a newer writer stored), for a key a store carries foreign, and for a key held as another type's record this document does not own. Advisory (the write still decides): it answers at call time, and a key nothing holds is - writable.""" + writable. It does not look at store paths: a link at a store path is not predicted, and the write refuses it with + `StoreLinkedError`.""" self._assert_registered(document) try: self._refuse_at(document, key) diff --git a/packages/cultcache-py/tests/test_cultcache.py b/packages/cultcache-py/tests/test_cultcache.py index f83cfa96..6b95f638 100644 --- a/packages/cultcache-py/tests/test_cultcache.py +++ b/packages/cultcache-py/tests/test_cultcache.py @@ -1485,6 +1485,32 @@ class CultCacheTests(unittest.TestCase): self.assertEqual(sorted(p.name for p in Path(tmp).iterdir()), ["link.jsonl", "volume"]) self.assertEqual(sorted(p.name for p in volume.iterdir()), ["store.jsonl"]) + # The advisory query answers for the held record and the cache's judges; a link at the store path is the write's to refuse. + @unittest.skipIf(os.name != "posix", "symbolic links need POSIX here") + def test_is_writable_over_a_store_at_a_live_link_answers_true_while_the_cache_write_is_refused(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + volume = Path(tmp) / "volume" + volume.mkdir() + target = volume / "store.cc" + document = item_doc() + seed = CultCache.builder().register_document_type(document).add_generic_store(SingleFileMessagePackBackingStore(target)).build() + seed.put(document, "held", Item(name="Held", category="c", value=1)) + before = target.read_bytes() + link = Path(tmp) / "link.cc" + link.symlink_to(target) + cache = CultCache.builder().register_document_type(document).add_generic_store(SingleFileMessagePackBackingStore(link)).build() + cache.pull_all_backing_stores() + names_before = (sorted(p.name for p in Path(tmp).iterdir()), sorted(p.name for p in volume.iterdir())) + + self.assertTrue(cache.is_writable(document, "held")) + self.assertTrue(cache.is_writable(document, "fresh")) + with self.assertRaises(StoreLinkedError) as caught: + cache.put(document, "fresh", Item(name="Fresh", category="d", value=2)) + self.assertEqual(Path(caught.exception.path), link) + self.assertTrue(link.is_symlink()) + self.assertEqual(target.read_bytes(), before) + self.assertEqual((sorted(p.name for p in Path(tmp).iterdir()), sorted(p.name for p in volume.iterdir())), names_before) + def test_store_linked_error_is_an_os_error_carrying_the_path(self) -> None: error = StoreLinkedError(Path("some/store.cc")) self.assertIsInstance(error, OSError) diff --git a/packages/cultcache-rs/CHANGELOG.md b/packages/cultcache-rs/CHANGELOG.md index b76a30d5..3978e248 100644 --- a/packages/cultcache-rs/CHANGELOG.md +++ b/packages/cultcache-rs/CHANGELOG.md @@ -6,6 +6,10 @@ only in git history. ## Unreleased +### Changed + +- `CultCache::is_writable` does not look at store paths: a link at a store path is not predicted, and the write refuses it with `StoreLinkedError`. Pinned by a test through a cache attached to a linked store. + ### Breaking Semver: this is a breaking change in the 0.x line (0.3.x to 0.4.0). diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index d4d6a64e..90c09435 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -3309,7 +3309,8 @@ impl CultCache { /// Whether a write or removal of `(type_id, key)` would be admitted: false when the held payload carries slots past /// its registered type's declared slots, at any depth, or cannot be read by this build at all (the record is /// read-only), when the cache holds no image of its stores (see [`CultCacheLoadRefused`]), when a store carries a foreign record at the key, or when another registered type holds it. True for a - /// key nothing holds. An unregistered type is an error. Advisory: the write entries ask the same checks themselves. + /// key nothing holds. An unregistered type is an error. It does not look at store paths: a link at a store path is not + /// predicted, and the write refuses it with [`StoreLinkedError`]. Advisory: the write entries ask the same checks themselves. pub fn is_writable(&self, type_id: &str, key: &str) -> Result { if self.require_loaded().is_err() { return Ok(false); @@ -8225,6 +8226,34 @@ mod tests { Ok(()) } + // The advisory query is the held record's and the cache's judges; a link at the store path is the write's to refuse. + #[cfg(unix)] + #[test] + fn is_writable_over_a_store_at_a_live_link_answers_true_while_the_cache_write_is_refused() -> Result<()> { + let temp = tempfile::tempdir()?; + let target = temp.path().join("store.cc"); + store_with(&target, |cache| cache.put("held", &settings_one()).map(drop))?; + let before = fs::read(&target)?; + let link = temp.path().join("link.cc"); + std::os::unix::fs::symlink(&target, &link)?; + + let mut cache = CultCache::new(); + cache.register_registry(TestEntries)?; + cache.add_backing_store(SingleFileMessagePackBackingStore::new(&link), [Settings::TYPE])?; + cache.pull_all_backing_stores()?; + let names_before = dir_names(temp.path())?; + + assert!(cache.is_writable(Settings::TYPE, "held")?, "the query does not look at the store path"); + assert!(cache.is_writable(Settings::TYPE, "fresh")?, "the query does not look at the store path"); + let error = cache.put("fresh", &settings_one()).unwrap_err(); + let linked = error.downcast_ref::().unwrap_or_else(|| panic!("{error:#}")); + assert_eq!(linked.path, link); + assert!(fs::symlink_metadata(&link)?.file_type().is_symlink(), "the link is left as it was"); + assert_eq!(fs::read(&target)?, before); + assert_eq!(dir_names(temp.path())?, names_before, "the refused write left no lock or staging file"); + Ok(()) + } + #[cfg(unix)] #[test] fn a_write_at_a_live_directory_link_is_refused_as_a_link() -> Result<()> { diff --git a/packages/cultcache-ts/CHANGELOG.md b/packages/cultcache-ts/CHANGELOG.md index c1a91df6..5583b45c 100644 --- a/packages/cultcache-ts/CHANGELOG.md +++ b/packages/cultcache-ts/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following ## [Unreleased] +### Changed + +- `CultCache.isWritable` does not look at store paths: a link at a store path is not predicted, and the write refuses it with `StoreLinkedError`. Pinned by a test through a cache attached to a linked store. + ### Breaking - A write at a single-file store path that is a symbolic link, live or dangling, is refused with the new `StoreLinkedError` (exported from the package entry; `error.path` names the path) before the lock and the replace, and writes nothing: `push`, `pushAll`, `delete` and a soft flush alike. Before, a write at a live link replaced the link with a regular file, a dangling link failed with `ENOENT` (or, for `pushAll`, was replaced), and a soft flush at a link skipped. A caller catching `ENOENT` around a write at a dangling link now catches `StoreLinkedError`; configure the link's target as the store path. Reads through a live link work. An `lstat` failure other than `ENOENT` (`ENOTDIR`, `EACCES`, `ELOOP`) is rethrown as it is. diff --git a/packages/cultcache-ts/src/cult-cache.ts b/packages/cultcache-ts/src/cult-cache.ts index a88bd66d..543356aa 100644 --- a/packages/cultcache-ts/src/cult-cache.ts +++ b/packages/cultcache-ts/src/cult-cache.ts @@ -615,7 +615,8 @@ export class CultCache { * another key, a record carrying unread slots nothing consents to). It answers the WRITE form. A removal asks the judge's * removal form and differs in two states: an owner's `delete` of a record held in another store than its own home succeeds * where this is false (a write would push to the wrong store), and `delete` of a global type at a key other than __global__ is - * the plain false of an unheld key where this is false. Live and advisory: the write or removal still decides. + * the plain false of an unheld key where this is false. It does not look at store paths: a link at a store path is not predicted, and + * the write refuses it with `StoreLinkedError`. Live and advisory: the write or removal still decides. */ isWritable(definition: AnyCultCacheDocumentDefinition, key: string): boolean { const registered = this.#requireDefinition(definition); diff --git a/packages/cultcache-ts/test/cult-cache.test.ts b/packages/cultcache-ts/test/cult-cache.test.ts index 0ea29c62..d3011ee2 100644 --- a/packages/cultcache-ts/test/cult-cache.test.ts +++ b/packages/cultcache-ts/test/cult-cache.test.ts @@ -2473,6 +2473,30 @@ test("SingleFileMessagePackBackingStore refuses a dangling symlink at its path a assert.deepEqual(await readdir(volume), []); }); +// The advisory query answers for the held record and the cache's judges; a link at the store path is the write's to refuse. +test("isWritable over a store at a live link answers true while the cache write is refused", { skip: process.platform === "win32" }, async () => { + const dir = await mkdtemp(join(tmpdir(), "cultcache-writable-link-")); + const volume = join(dir, "volume"); + await mkdir(volume); + const target = join(volume, "store.cc"); + const link = join(dir, "link.cc"); + const document = defineDocumentType({ type: "note", schema: z.object({ text: z.string() }) }); + await CultCache.builder().withDocumentType(document).withGenericStore(new SingleFileMessagePackBackingStore(target)).build() + .put(document, "held", { text: "held" }); + const before = await readFile(target); + await symlink(target, link); + const cache = CultCache.builder().withDocumentType(document).withGenericStore(new SingleFileMessagePackBackingStore(link)).build(); + await cache.pullAllBackingStores(); + const namesBefore = [(await readdir(dir)).sort(), (await readdir(volume)).sort()]; + + assert.equal(cache.isWritable(document, "held"), true); + assert.equal(cache.isWritable(document, "fresh"), true); + await assert.rejects(() => cache.put(document, "fresh", { text: "fresh" }), (error: unknown) => error instanceof StoreLinkedError && error.path === link); + assert.ok((await lstat(link)).isSymbolicLink()); + assert.deepEqual(await readFile(target), before); + assert.deepEqual([(await readdir(dir)).sort(), (await readdir(volume)).sort()], namesBefore); +}); + // The second look of the read, staged: nothing is at the path only when the read and the lstat both find nothing. A path // that changed between them (a link in a parent swapped for a file, a store that appeared) is never an empty store. test("readStoreBytes reports nothing at the path only when the lstat finds nothing too", async () => { diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index 3af8ba70..469fc7af 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -2676,7 +2676,8 @@ namespace GameCult.Caching /// when that store holds it unowned (undeclared drift that drops persisted slots, or a payload carrying slots past this build's, /// see ). For a key the cache does not hold, the removal check every store makes of it /// (false when a store lists it foreign). One write it does not predict: a Flatten of a variant whose base chain holds an unowned record is refused, though - /// the variant itself is writable. Live and advisory: the write still decides. + /// the variant itself is writable. It does not look at store paths: a link at a store path is not predicted, and the write refuses it + /// with . Live and advisory: the write still decides. /// public bool IsWritable(CultRecordKey key) { diff --git a/tests/GameCult.Caching.Tests/StoreLinkRefusalTests.cs b/tests/GameCult.Caching.Tests/StoreLinkRefusalTests.cs index 4444b176..64536b77 100644 --- a/tests/GameCult.Caching.Tests/StoreLinkRefusalTests.cs +++ b/tests/GameCult.Caching.Tests/StoreLinkRefusalTests.cs @@ -170,6 +170,26 @@ namespace GameCult.Caching.Tests Assert.That(IsALink(link), "the link was replaced"); } + [Test] + public void AnAdvisoryQueryOverACacheAtALiveLinkAnswersForTheRecordAndTheWriteIsStillRefused() + { + var path = PathOf("store.cc"); + var link = PathOf("link.cc"); + SeedSingle(path); + Link(link, path); + var before = File.ReadAllBytes(path); + using var cache = CultCacheMessagePack.Create(link, new CultCacheOpenOptions { Registry = Registry, FlushOnDispose = false, StoreFlushOnDispose = false }); + + Assert.That(cache.IsWritable(new CultRecordKey("seed")), Is.True, "the query does not look at the store path"); + Assert.That(cache.IsWritable(new CultRecordKey("fresh")), Is.True, "the query does not look at the store path"); + var refusal = Assert.Throws(() => + cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "t" }, new CultRecordKey("fresh"))))!; + + Assert.That(refusal.Path, Is.EqualTo(link)); + Assert.That(IsALink(link), "the link was replaced"); + Assert.That(File.ReadAllBytes(path), Is.EqualTo(before)); + } + // A batch that upserts into one store and removes a key the cache does not hold has the other store judge that removal // under its own lock (JudgeChecks). private CultCache OpenTwoStores(string homePath, string otherPath) From 0e8a95ed07c4975047285d95617277b4b8017a90 Mon Sep 17 00:00:00 2001 From: Libby Date: Fri, 13 Nov 2026 04:55:47 +0000 Subject: [PATCH 75/93] cultcache-rs: the unlock-fault test tolerates a fork-exec duplicate of the lock descriptor (rs-unlock-fault-test-flake) (#156) --- packages/cultcache-rs/src/lib.rs | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index 90c09435..edfc0c91 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -8176,11 +8176,21 @@ mod tests { with_write_fault(WriteStep::Unlock, || store.push(&pushed))?; assert!(with_write_fault(WriteStep::Unlock, || store.pull_all())?.contains(&pushed)); - // The lock was released: another writer takes it at once. - assert!(matches!( - store.try_compare_exchange_snapshot(&store.pull_all()?, &replacement)?, - TryCompareExchangeSnapshotOutcome::Exchanged - )); + // The lock was released: another writer takes it. The shared lock of the faulted `pull_all` just above was left to the + // close of its descriptor (see `release_lock`), which frees it only once no other descriptor shares its open file + // description, and a test that spawns a child (a fork-exec duplicates every descriptor of the process until the exec) + // can hold such a duplicate on another test thread for a moment. A faulted exclusive lock cannot contend the try-lock: + // the blocking `pull_all` in its argument waits it out. So a contended try-lock is retried for up to two seconds; a + // lock that was never released stays contended and still fails. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + let outcome = loop { + let outcome = store.try_compare_exchange_snapshot(&store.pull_all()?, &replacement)?; + if !matches!(outcome, TryCompareExchangeSnapshotOutcome::LockContended) || std::time::Instant::now() >= deadline { + break outcome; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + }; + assert!(matches!(outcome, TryCompareExchangeSnapshotOutcome::Exchanged), "{outcome:?}"); // The keyed store releases its lock the same way. let mut keyed = RedbMessagePackBackingStore::new(temp.path().join("keyed.redb"))?; From d6708631dadc6b85f3a375ab307ba12fcce04c90 Mon Sep 17 00:00:00 2001 From: Libby Date: Fri, 13 Nov 2026 05:26:13 +0000 Subject: [PATCH 76/93] cultcache-rs and cultnet-rs commit Cargo.lock; CI builds them --locked (#157) --- .github/workflows/cultmesh-portability.yml | 2 +- .github/workflows/cultnet-interop.yml | 2 +- packages/cultcache-rs/.gitignore | 1 - packages/cultcache-rs/Cargo.lock | 730 ++++++++++++ packages/cultnet-rs/.gitignore | 1 - packages/cultnet-rs/Cargo.lock | 1227 ++++++++++++++++++++ 6 files changed, 1959 insertions(+), 4 deletions(-) create mode 100644 packages/cultcache-rs/Cargo.lock create mode 100644 packages/cultnet-rs/Cargo.lock diff --git a/.github/workflows/cultmesh-portability.yml b/.github/workflows/cultmesh-portability.yml index 0b8aca1d..bafe70fc 100644 --- a/.github/workflows/cultmesh-portability.yml +++ b/.github/workflows/cultmesh-portability.yml @@ -158,7 +158,7 @@ jobs: # (only the interop example built, inside the C# store tests). The target directory is under RUNNER_TEMP, away from the checkout. - name: Verify the CultCache document owner with the Rust cache shell: bash - run: CARGO_TARGET_DIR="$RUNNER_TEMP/cultcache-rs-target" cargo test --manifest-path packages/cultcache-rs/Cargo.toml + run: CARGO_TARGET_DIR="$RUNNER_TEMP/cultcache-rs-target" cargo test --locked --manifest-path packages/cultcache-rs/Cargo.toml # tests/GameCult.Caching.Tests, the C# reference cache's own suite (about 1,260 tests, under a minute), ran in no # forge workflow; only the Mesh tests above exercised the reference. diff --git a/.github/workflows/cultnet-interop.yml b/.github/workflows/cultnet-interop.yml index e053e123..750edc9a 100644 --- a/.github/workflows/cultnet-interop.yml +++ b/.github/workflows/cultnet-interop.yml @@ -135,7 +135,7 @@ jobs: python -m unittest discover -s packages/cultmesh-py/tests - name: Verify Rust RUDP reliability - run: cargo test --manifest-path packages/cultnet-rs/Cargo.toml rudp + run: cargo test --locked --manifest-path packages/cultnet-rs/Cargo.toml rudp - name: Verify Rust CultMesh runtime-local surface run: cargo test --manifest-path packages/cultmesh-rs/Cargo.toml diff --git a/packages/cultcache-rs/.gitignore b/packages/cultcache-rs/.gitignore index 96ef6c0b..ea8c4bf7 100644 --- a/packages/cultcache-rs/.gitignore +++ b/packages/cultcache-rs/.gitignore @@ -1,2 +1 @@ /target -Cargo.lock diff --git a/packages/cultcache-rs/Cargo.lock b/packages/cultcache-rs/Cargo.lock new file mode 100644 index 00000000..40d9e1e8 --- /dev/null +++ b/packages/cultcache-rs/Cargo.lock @@ -0,0 +1,730 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "ar_archive_writer" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "523e5e187fff36fcb8d1d19ccf0ca4ca43ec3d158316057f66dc6f72716bc5ec" +dependencies = [ + "object", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cc" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ff2a8126fff0f8c839080d619675a585e61720bb12d2baceae45769fd66f155" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cultcache-rs" +version = "0.3.1" +dependencies = [ + "anyhow", + "chrono", + "cultcache-rs-derive", + "fs2", + "pretty_assertions", + "redb", + "rmp-serde", + "serde", + "serde_bytes", + "serde_json", + "serde_stacker", + "stacker", + "tempfile", + "uuid", + "windows-sys 0.59.0", +] + +[[package]] +name = "cultcache-rs-derive" +version = "0.2.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "errno" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35d6e78f4cebb90e51d543db8fe41528851d6deeef6ef21ebd4bfdc1e784776" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "itoa" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bd1574e617a9a6c1f8f8181bd7e841e1f20e4277607ef28945765188d6284d4" + +[[package]] +name = "js-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "psm" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622" +dependencies = [ + "ar_archive_writer", + "cc", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redb" +version = "4.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b317ef681e18f6091efbfab532eaf924a135c492d9d0299d0fbfae07e92a39b" +dependencies = [ + "libc", +] + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rustix" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.10", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_stacker" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4936375d50c4be7eff22293a9344f8e46f323ed2b3c243e52f89138d9bb0f4a" +dependencies = [ + "serde", + "serde_core", + "stacker", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "stacker" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.61.2", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae9ccbea3a7b0b09443036490c8f34a8b1b760fd345e5c0dae0aac718faea79" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "uuid" +version = "1.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bed99139f7dac9b5cfe5f17be4d66dc7079a34dee1d74bcc77217119240e267c" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" +dependencies = [ + "cfg-if", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.10", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/packages/cultnet-rs/.gitignore b/packages/cultnet-rs/.gitignore index 96ef6c0b..ea8c4bf7 100644 --- a/packages/cultnet-rs/.gitignore +++ b/packages/cultnet-rs/.gitignore @@ -1,2 +1 @@ /target -Cargo.lock diff --git a/packages/cultnet-rs/Cargo.lock b/packages/cultnet-rs/Cargo.lock new file mode 100644 index 00000000..ed0aab43 --- /dev/null +++ b/packages/cultnet-rs/Cargo.lock @@ -0,0 +1,1227 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "ar_archive_writer" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "523e5e187fff36fcb8d1d19ccf0ca4ca43ec3d158316057f66dc6f72716bc5ec" +dependencies = [ + "object", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cc" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ff2a8126fff0f8c839080d619675a585e61720bb12d2baceae45769fd66f155" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "cultcache-rs" +version = "0.3.1" +dependencies = [ + "anyhow", + "chrono", + "cultcache-rs-derive", + "fs2", + "redb", + "rmp-serde", + "serde", + "serde_bytes", + "serde_json", + "serde_stacker", + "stacker", + "uuid", + "windows-sys 0.59.0", +] + +[[package]] +name = "cultcache-rs-derive" +version = "0.2.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "cultnet-rs" +version = "0.3.0" +dependencies = [ + "aes-gcm", + "anyhow", + "base64", + "chrono", + "cultcache-rs", + "ed25519-dalek", + "fs2", + "hmac", + "libc", + "pretty_assertions", + "rand", + "rmp-serde", + "rmpv", + "serde", + "serde_bytes", + "serde_json", + "sha2", + "socket2", + "tempfile", + "uuid", + "windows-sys 0.61.2", + "zeroize", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "errno" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35d6e78f4cebb90e51d543db8fe41528851d6deeef6ef21ebd4bfdc1e784776" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "itoa" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bd1574e617a9a6c1f8f8181bd7e841e1f20e4277607ef28945765188d6284d4" + +[[package]] +name = "js-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "psm" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622" +dependencies = [ + "ar_archive_writer", + "cc", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redb" +version = "4.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b317ef681e18f6091efbfab532eaf924a135c492d9d0299d0fbfae07e92a39b" +dependencies = [ + "libc", +] + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rmpv" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a4e1d4b9b938a26d2996af33229f0ca0956c652c1375067f0b45291c1df8417" +dependencies = [ + "rmp", + "serde", + "serde_bytes", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.10", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_stacker" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4936375d50c4be7eff22293a9344f8e46f323ed2b3c243e52f89138d9bb0f4a" +dependencies = [ + "serde", + "serde_core", + "stacker", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stacker" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.61.2", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae9ccbea3a7b0b09443036490c8f34a8b1b760fd345e5c0dae0aac718faea79" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "uuid" +version = "1.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bed99139f7dac9b5cfe5f17be4d66dc7079a34dee1d74bcc77217119240e267c" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" +dependencies = [ + "cfg-if", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.10", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" From d7e94648ac27f216bd09214096c6ba64c00508b9 Mon Sep 17 00:00:00 2001 From: Libby Date: Fri, 13 Nov 2026 05:43:49 +0000 Subject: [PATCH 77/93] Python: the BackingStore protocol can say absent, so the default soft push_all decides like the real stores (py-protocol-pull-absent) (#158) --- packages/cultcache-py/CHANGELOG.md | 2 +- .../src/cultcache_py/backing_store.py | 15 ++++-- .../cultcache-py/src/cultcache_py/stores.py | 6 +++ .../cultcache-py/tests/test_soft_push_all.py | 48 +++++++++++++++---- 4 files changed, 58 insertions(+), 13 deletions(-) diff --git a/packages/cultcache-py/CHANGELOG.md b/packages/cultcache-py/CHANGELOG.md index e5a8b8e8..36a7d47e 100644 --- a/packages/cultcache-py/CHANGELOG.md +++ b/packages/cultcache-py/CHANGELOG.md @@ -16,7 +16,7 @@ ### Breaking -- `push_all(envelopes, soft=False)` on the single-file and JSONL stores and on the `BackingStore` protocol takes the soft flag, as TypeScript, Rust and C# do: a soft push writes only where nothing is at the path; over a store the reader reads, empty included, it returns without writing, and over a file the reader cannot read it refuses (`StoreUnreadableError` for the single-file store). The `BackingStore` protocol's default `push_all` cannot tell an absent store from an empty one (a pull returns a list), so a soft push through it always skips a store whose pull returns; a store kind that can tell nothing-at-the-path overrides `push_all`. A store kind that overrides `push_all(envelopes)` without the keyword fails when a caller passes `soft`. +- `push_all(envelopes, soft=False)` on the single-file and JSONL stores and on the `BackingStore` protocol takes the soft flag, as TypeScript, Rust and C# do: a soft push writes only where nothing is at the path; over a store the reader reads, empty included, it returns without writing, and over a file the reader cannot read it refuses (`StoreUnreadableError` for the single-file store). The `BackingStore` protocol has `pull_or_absent()`: the envelopes the reader reads, `None` when nothing is at the path, a raise when the path cannot be read; its default is `pull_all()`, which cannot say absent, so a store that inherits it is always read. The default `push_all` decides soft by it (writes where it says absent, skips over a store it read, refuses where it raises), and the single-file and JSONL stores expose their pull through it; an inheriting store that can tell nothing-at-the-path overrides `pull_or_absent`, not `push_all`. Before, a soft push through the default always skipped. A store kind that overrides `push_all(envelopes)` without the keyword fails when a caller passes `soft`. - A write at a store path that is a link, live or dangling, is refused with the new `StoreLinkedError` (an `OSError`, exported from `cultcache_py`; `.path` names the path) before the lock and the replace, for the single-file store and for `JsonLinesBackingStore` (`push`, `push_all`, `delete`), and nothing is written. Before, a write at a live link replaced the link with a regular file, and at a dangling link both stores raised `FileNotFoundError`. A caller catching `FileNotFoundError` there now catches `StoreLinkedError` (`OSError`); configure the link's target as the store path. On Windows a junction or any reparse point counts as a link. Reads through a live link work. - A record whose payload carries a slot past its reader's declared slots, at any depth (the document, a nested record, a list element, a union arm, a unit arm), is read, held read-only, and every write or removal of its key (`put`, `put_global`, `update`, `put_envelope`, `put_envelopes`, `delete`, `delete_global`) is refused with `SchemaConflictError` before any store is touched (`cultlib-gaps:ruling:extra-trailing-slots`). Reading dropped those slots silently and the next write shed them. `SchemaConflictError.unread_slots` is an `UnreadSlots(type_name, slot_count, declared_count, occurrences, depth0_only)` and the message names the type and both counts, never a slot value. A depth-0 mark is consented, and the record writable, only when the record is stored under an id the document lists in `compatible_schema_ids` and that id's catalog entry has slots past the document's, covering the mark's slot count (`cultlib-gaps:ruling:slot-mark-consent`); a nested mark, or a mark under the document's own id, keeps the record read-only whatever is declared. Missing trailing slots keep their declared defaults. An untouched marked record keeps its stored bytes through every other write. Added `CultCache.is_writable(document, key)` (advisory; the write still decides: it also answers False for a foreign-carried key, for a key held as a type the document does not own, and for a global document at a non-global key; `delete` asks the same judges, so removing a key held as another type is refused with `SchemaConflictError` unless the document owns that record's schema id (its own or one it lists compatible), in which case the removal removes that record, its file row and its lookups, as a write of the key would replace it; it returned silently and removed nothing), `DocumentDefinition.decode_payload_marked`, and `UnreadSlots`. A document with a custom `decode` (no slot codec) never marks. A store holding a variant record is still unreadable here. Implies a minor bump while the version is 0.x (0.3.0 to 0.4.0): code that wrote back a record from a newer writer now gets `SchemaConflictError`. - A payload is one MessagePack value: `DocumentDefinition.decode_payload` refuses bytes after it, by count, with `ValueError` ` payload has N bytes after its first MessagePack value (refused on read).` (never the bytes, never the depth refusal; a payload that ends early stays the decoder's), as the C# reference does (`tests/vectors/union-parity/bytes-after-outcomes.msgpack`). It raised `msgpack.ExtraData` (a `ValueError`, so only a caller naming `msgpack.ExtraData` is affected); this is a minor-version bump while pre-1.0. The refused set is unchanged. Cache loads, `get` and every definition built on the MessagePack decoder go through it; a user `payload_decoder` that raises `ExtraData` is refused the same way. Depth is judged first, so a value nested past 256 with a tail is the depth refusal, as in C#. diff --git a/packages/cultcache-py/src/cultcache_py/backing_store.py b/packages/cultcache-py/src/cultcache_py/backing_store.py index 7b07e7be..bded4b11 100644 --- a/packages/cultcache-py/src/cultcache_py/backing_store.py +++ b/packages/cultcache-py/src/cultcache_py/backing_store.py @@ -67,12 +67,19 @@ class BackingStore(Protocol): def delete(self, type: str, key: str) -> None: ... + def pull_or_absent(self) -> list[CultCacheEnvelope] | None: + """The envelopes the reader reads (empty included), None when nothing is at the path; raises when the path cannot be read. + + The default is pull_all, which cannot say absent: a store that inherits it is always a store the reader read. A store kind + that can tell nothing-at-the-path overrides this, and the default push_all then writes where nothing is on a soft push.""" + return self.pull_all() + def push_all(self, envelopes: list[CultCacheEnvelope], soft: bool = False) -> None: - # A pull that returns is a store the reader read, empty included: the protocol cannot say "absent", so a soft push - # through this default always skips. A store that can tell nothing-at-the-path overrides push_all. - pulled = self.pull_all() - if soft: + # Soft: write only where pull_or_absent says nothing is; skip over a store it read, empty included; its raise refuses. + pulled = self.pull_or_absent() + if soft and pulled is not None: return + pulled = pulled or [] existing = {(envelope.type, envelope.key): envelope for envelope in pulled} for envelope in envelopes: existing[(envelope.type, envelope.key)] = envelope diff --git a/packages/cultcache-py/src/cultcache_py/stores.py b/packages/cultcache-py/src/cultcache_py/stores.py index 36729d44..b6b848bc 100644 --- a/packages/cultcache-py/src/cultcache_py/stores.py +++ b/packages/cultcache-py/src/cultcache_py/stores.py @@ -78,6 +78,9 @@ class JsonLinesBackingStore: def pull_all(self) -> list[CultCacheEnvelope]: return self._pull() or [] + def pull_or_absent(self) -> list[CultCacheEnvelope] | None: + return self._pull() + def _pull(self) -> list[CultCacheEnvelope] | None: """The envelopes the reader reads, or None when nothing is at the path.""" with self._lock: @@ -152,6 +155,9 @@ class SingleFileMessagePackBackingStore: def pull_all(self) -> list[CultCacheEnvelope]: return self._pull() or [] + def pull_or_absent(self) -> list[CultCacheEnvelope] | None: + return self._pull() + def _pull(self) -> list[CultCacheEnvelope] | None: """The envelopes the reader reads, or None when nothing is at the path.""" with self._lock: diff --git a/packages/cultcache-py/tests/test_soft_push_all.py b/packages/cultcache-py/tests/test_soft_push_all.py index 9a2c6e03..d30483a3 100644 --- a/packages/cultcache-py/tests/test_soft_push_all.py +++ b/packages/cultcache-py/tests/test_soft_push_all.py @@ -69,8 +69,33 @@ class SoftPushAllTests(unittest.TestCase): JsonLinesBackingStore(self.path).push_all([envelope("a")], soft=True) self.assertEqual(junk, self.path.read_bytes()) - def test_the_protocol_default_push_all_skips_what_its_pull_read_empty_included(self) -> None: + def test_the_protocol_default_push_all_decides_soft_by_what_pull_or_absent_says(self) -> None: class Memory(BackingStore): + def __init__(self) -> None: + self.items: list[CultCacheEnvelope] | None = None # None: nothing at the path + + def pull_all(self) -> list[CultCacheEnvelope]: + return list(self.items or []) + + def pull_or_absent(self) -> list[CultCacheEnvelope] | None: + return None if self.items is None else list(self.items) + + def _replace_all(self, envelopes: list[CultCacheEnvelope]) -> None: + self.items = list(envelopes) + + store = Memory() + store.push_all([envelope("a")], soft=True) # nothing at the path: writes + self.assertEqual(["a"], [item.key for item in store.items or []]) + store.push_all([envelope("b")], soft=True) # a store with records: skips + self.assertEqual(["a"], [item.key for item in store.items or []]) + store.items = [] + store.push_all([envelope("b")], soft=True) # a store the reader read, empty: skips + self.assertEqual([], store.items) + store.push_all([envelope("b")]) # plain: lands + self.assertEqual(["b"], [item.key for item in store.items]) + + def test_a_store_that_only_pulls_all_is_read_so_a_soft_push_skips_it(self) -> None: + class PullOnly(BackingStore): def __init__(self) -> None: self.items: list[CultCacheEnvelope] = [] @@ -80,14 +105,18 @@ class SoftPushAllTests(unittest.TestCase): def _replace_all(self, envelopes: list[CultCacheEnvelope]) -> None: self.items = list(envelopes) - store = Memory() - store.push_all([envelope("a")], soft=True) # the pull read an empty store: skips + store = PullOnly() + store.push_all([envelope("a")], soft=True) self.assertEqual([], store.items) - store.push_all([envelope("a")]) # plain: lands - store.push_all([envelope("b")], soft=True) # a store with records: skips - self.assertEqual(["a"], [item.key for item in store.items]) - store.push_all([envelope("b")]) - self.assertEqual(["a", "b"], sorted(item.key for item in store.items)) + + def test_the_single_file_and_jsonl_stores_say_absent_and_read_through_pull_or_absent(self) -> None: + for make in (SingleFileMessagePackBackingStore, JsonLinesBackingStore): + with self.subTest(make.__name__): + path = self.dir / make.__name__ + store = make(path) + self.assertIsNone(store.pull_or_absent()) + store.push_all([envelope("a")]) + self.assertEqual(["a"], [item.key for item in store.pull_or_absent() or []]) def test_the_protocol_default_push_all_refuses_where_its_pull_refuses(self) -> None: class Unreadable(BackingStore): @@ -96,6 +125,9 @@ class SoftPushAllTests(unittest.TestCase): def pull_all(self) -> list[CultCacheEnvelope]: raise StoreUnreadableError(Path("x"), ValueError("junk")) + def pull_or_absent(self) -> list[CultCacheEnvelope] | None: + raise StoreUnreadableError(Path("x"), ValueError("junk")) + def _replace_all(self, envelopes: list[CultCacheEnvelope]) -> None: Unreadable.replaced = True From 694d2ced1061137c65e6bc7dc8aacc01b9a30e04 Mon Sep 17 00:00:00 2001 From: Libby Date: Fri, 13 Nov 2026 06:12:54 +0000 Subject: [PATCH 78/93] Rust, Python and TypeScript stores re-check for a link once the store lock is held (link-late-check-rs-py-ts) (#159) --- packages/cultcache-py/CHANGELOG.md | 1 + .../cultcache-py/src/cultcache_py/stores.py | 2 + .../cultcache-py/tests/test_store_lock.py | 44 ++++++++++++++++ packages/cultcache-rs/CHANGELOG.md | 1 + packages/cultcache-rs/src/lib.rs | 50 ++++++++++++++++++- packages/cultcache-ts/CHANGELOG.md | 1 + packages/cultcache-ts/src/store-file.ts | 2 + packages/cultcache-ts/test/store-file.test.ts | 37 ++++++++++++++ 8 files changed, 136 insertions(+), 2 deletions(-) diff --git a/packages/cultcache-py/CHANGELOG.md b/packages/cultcache-py/CHANGELOG.md index 36a7d47e..77324c91 100644 --- a/packages/cultcache-py/CHANGELOG.md +++ b/packages/cultcache-py/CHANGELOG.md @@ -8,6 +8,7 @@ ### Changed +- `store_write_lock` looks for a link at the store path again once the lock is held, so a link put at the path while a writer waited on the lock is refused with `StoreLinkedError` and the lock released, instead of being replaced. `JsonLinesBackingStore` takes no cross-process lock (only its thread lock, under which it already refuses a link), so it has no lock wait to re-check across. Pinned by a test that puts the link while a foreign process holds the lock. - `CultCache.is_writable` does not look at store paths: a link at a store path is not predicted, and the write refuses it with `StoreLinkedError`. Pinned by a test through a cache attached to a linked store. - `JsonLinesBackingStore` writes LF line endings on Windows. It wrote CRLF there, through `write_text` newline translation, before the commit_store_file change (#154, adbbb780). - `msgpack` is a hard dependency, as `pyproject.toml` declared it. The optional-import fallbacks and the `cultcache-py[msgpack]` extra they named (which never existed) are gone: a missing msgpack is an `ImportError` at import. diff --git a/packages/cultcache-py/src/cultcache_py/stores.py b/packages/cultcache-py/src/cultcache_py/stores.py index b6b848bc..894252c8 100644 --- a/packages/cultcache-py/src/cultcache_py/stores.py +++ b/packages/cultcache-py/src/cultcache_py/stores.py @@ -501,6 +501,8 @@ def store_write_lock(path: Path, timeout: float = LOCK_TIMEOUT_SECONDS) -> Itera raise TimeoutError(f"CultCache timed out after {timeout} s waiting for the store lock {lock_path}.") time.sleep(_LOCK_POLL_SECONDS) try: + # A link may have been put at the path while the lock was awaited; the look above only saw the path before. + _refuse_linked(path) yield finally: release() diff --git a/packages/cultcache-py/tests/test_store_lock.py b/packages/cultcache-py/tests/test_store_lock.py index 5ee29aa2..50b95436 100644 --- a/packages/cultcache-py/tests/test_store_lock.py +++ b/packages/cultcache-py/tests/test_store_lock.py @@ -5,12 +5,14 @@ import shutil import subprocess import sys import tempfile +import threading import time import unittest from pathlib import Path from unittest import mock from cultcache_py import SingleFileMessagePackBackingStore, stores +from cultcache_py.stores import StoreLinkedError, store_write_lock from cultcache_py.backing_store import CultCacheEnvelope REPO = Path(__file__).resolve().parents[3] @@ -73,6 +75,48 @@ class StoreLockTests(unittest.TestCase): holder = start_holder(["node", "-e", TS_HOLDER, str(TS_STORE_FILE), str(self.path), str(HOLD_SECONDS)]) self.assert_write_waits_for(holder) + def test_a_link_put_while_a_writer_waits_on_the_lock_is_refused_once_it_holds_it(self) -> None: + target = self.dir / "volume" / "store.cc" + target.parent.mkdir() + SingleFileMessagePackBackingStore(target).push(envelope("a")) + before = target.read_bytes() + writes = { + "push": lambda store: store.push(envelope("b")), + "push_all": lambda store: store.push_all([envelope("b")]), + "delete": lambda store: store.delete("t", "a"), + } + missing = self.dir / "volume" / "missing.cc" + for (name, write), link_to in [(item, to) for item in writes.items() for to in (target, missing)]: + with self.subTest(name, dangling=link_to == missing): + if os.path.lexists(self.path): + os.unlink(self.path) + holder = start_holder([sys.executable, "-c", PY_HOLDER, str(self.path), str(HOLD_SECONDS)]) + self.addCleanup(holder.wait) + self.addCleanup(holder.stdout.close) # type: ignore[union-attr] + outcome: list[BaseException | None] = [] + + def run() -> None: + try: + write(SingleFileMessagePackBackingStore(self.path, lock_timeout=10)) + outcome.append(None) + except BaseException as exc: # noqa: BLE001 - the thread hands the outcome to the test + outcome.append(exc) + + writer = threading.Thread(target=run) + writer.start() + time.sleep(0.3) + os.symlink(link_to, self.path) # after the writer's pre-lock look, while it waits + writer.join() + holder.wait() + self.assertEqual(1, len(outcome)) + self.assertIsInstance(outcome[0], StoreLinkedError) + self.assertTrue(os.path.islink(self.path), "the link was replaced") + self.assertEqual(before, target.read_bytes()) + self.assertFalse(missing.exists(), "the dangling target was created") + os.unlink(self.path) # a link is refused before the lock, so remove it to probe the writer's own lock file + with store_write_lock(self.path, timeout=0.2): # the lock is free + pass + def test_the_lock_is_released_after_each_write(self) -> None: store = SingleFileMessagePackBackingStore(self.path, lock_timeout=0.2) for key in "abc": diff --git a/packages/cultcache-rs/CHANGELOG.md b/packages/cultcache-rs/CHANGELOG.md index 3978e248..327f42aa 100644 --- a/packages/cultcache-rs/CHANGELOG.md +++ b/packages/cultcache-rs/CHANGELOG.md @@ -8,6 +8,7 @@ only in git history. ### Changed +- The single-file store re-checks for a link once the store lock is held (every write and the try-exchange), so a link put at the path while a writer waited on the lock is refused with `StoreLinkedError` and the lock released, as C# does, instead of being replaced. Pinned by a test that puts the link while a second holder owns the lock. - `CultCache::is_writable` does not look at store paths: a link at a store path is not predicted, and the write refuses it with `StoreLinkedError`. Pinned by a test through a cache attached to a linked store. ### Breaking diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index edfc0c91..9ba800e9 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -1752,7 +1752,7 @@ impl SingleFileMessagePackBackingStore { let lock = self.open_lock_file()?; fs2::FileExt::lock_exclusive(&lock) .with_context(|| format!("failed to lock {}", self.lock_path().display()))?; - release_lock(lock, action()) + self.run_locked(lock, action) } /// Replaces the named entries only if each still matches its expectation. @@ -1870,7 +1870,13 @@ impl SingleFileMessagePackBackingStore { .with_context(|| format!("failed to lock {}", self.lock_path().display())); } } - release_lock(lock, action()).map(Some) + self.run_locked(lock, action).map(Some) + } + + /// The one tail of both exclusive entries: with the lock held, look for a link again (one may have been put at the + /// path while the lock was awaited; the look before the lock saw the path earlier), run the action, release. + fn run_locked(&self, lock: File, action: impl FnOnce() -> Result) -> Result { + release_lock(lock, refuse_linked(&self.path).and_then(|()| action())) } fn open_lock_file(&self) -> Result { @@ -8199,6 +8205,46 @@ mod tests { Ok(()) } + // A link put at the store path while a writer waits on the lock is refused once the lock is taken, as C# does, and the + // lock is free afterwards. + #[cfg(unix)] + #[test] + fn a_link_put_while_a_writer_waits_on_the_lock_is_refused_once_it_holds_it() -> Result<()> { + let temp = tempfile::tempdir()?; + let volume = temp.path().join("volume"); + fs::create_dir(&volume)?; + let target = volume.join("store.cc"); + SingleFileMessagePackBackingStore::new(&target).push(&snapshot_envelope("a", b"1"))?; + let before = fs::read(&target)?; + let dangling = volume.join("missing.cc"); + let path = temp.path().join("late.cc"); + let writers: [(&str, fn(&mut SingleFileMessagePackBackingStore) -> Result<()>); 4] = [ + ("push", |s| s.push(&snapshot_envelope("b", b"2"))), + ("push_all", |s| s.push_all(&[snapshot_envelope("b", b"2")], PushAllOptions::default())), + ("delete", |s| s.delete(&snapshot_envelope("a", b"1"))), + ("compare_exchange_snapshot", |s| s.compare_exchange_snapshot(&[], &[snapshot_envelope("b", b"2")]).map(|_| ())), + ]; + for (name, write, link_to) in writers.into_iter().flat_map(|(n, w)| [(n, w, &target), (n, w, &dangling)]) { + let _ = fs::remove_file(&path); + let store = SingleFileMessagePackBackingStore::new(&path); + let holder = store.open_lock_file()?; + fs2::FileExt::lock_exclusive(&holder)?; + let mut waiting = SingleFileMessagePackBackingStore::new(&path); + let handle = std::thread::spawn(move || write(&mut waiting)); + std::thread::sleep(std::time::Duration::from_millis(300)); + std::os::unix::fs::symlink(link_to, &path)?; + fs2::FileExt::unlock(&holder)?; + let error = handle.join().expect("writer thread").expect_err("the late link must be refused"); + assert!(error.downcast_ref::().is_some(), "{name}: {error:#}"); + assert!(fs::symlink_metadata(&path)?.file_type().is_symlink(), "{name}: the link was replaced"); + assert_eq!(fs::read(&target)?, before, "{name}: the target changed"); + assert!(!dangling.exists(), "{name}: the dangling target was created"); + let probe = store.open_lock_file()?; + fs2::FileExt::try_lock_exclusive(&probe).map_err(|e| anyhow!("{name}: lock still held: {e}"))?; + } + Ok(()) + } + // A write at a store path that is a link is refused: a replace there would swap the link for a regular file and // detach the store from its target. Reads through a live link keep working. #[cfg(unix)] diff --git a/packages/cultcache-ts/CHANGELOG.md b/packages/cultcache-ts/CHANGELOG.md index 5583b45c..c41071a7 100644 --- a/packages/cultcache-ts/CHANGELOG.md +++ b/packages/cultcache-ts/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following ### Changed +- `withStoreWriteLock` looks for a link at the store path again once the lock is held, so a link put at the path while a writer waited on the lock is refused with `StoreLinkedError` and the lock released, instead of being replaced. Pinned by a test that puts the link while another holder owns the lock. - `CultCache.isWritable` does not look at store paths: a link at a store path is not predicted, and the write refuses it with `StoreLinkedError`. Pinned by a test through a cache attached to a linked store. ### Breaking diff --git a/packages/cultcache-ts/src/store-file.ts b/packages/cultcache-ts/src/store-file.ts index e6988d23..0488960e 100644 --- a/packages/cultcache-ts/src/store-file.ts +++ b/packages/cultcache-ts/src/store-file.ts @@ -218,6 +218,8 @@ export async function withStoreWriteLock( } try { + // A link may have been put at the path while the lock was awaited; the look above only saw the path before. + await refuseLinkedStore(storePath); return await operation(); } finally { release(); diff --git a/packages/cultcache-ts/test/store-file.test.ts b/packages/cultcache-ts/test/store-file.test.ts index 485df058..7ddf7fe3 100644 --- a/packages/cultcache-ts/test/store-file.test.ts +++ b/packages/cultcache-ts/test/store-file.test.ts @@ -417,6 +417,43 @@ test("a second writer in this process waits for the store lock and runs after re } }); +test("a link put while a writer waits on the store lock is refused once it holds the lock", { skip: process.platform === "win32" }, async () => { + const directory = await scratch(); + try { + const target = join(directory, "target.cc"); + await new SingleFileMessagePackBackingStore(target).push(noteEnvelope("a")); + const before = await readFile(target); + const writes: Array<[string, (store: SingleFileMessagePackBackingStore) => Promise]> = [ + ["push", (store) => store.push(noteEnvelope("b"))], + ["pushAll", (store) => store.pushAll([noteEnvelope("b")])], + ["delete", (store) => store.delete(noteEnvelope("a"))], + ]; + const missing = join(directory, "missing.cc"); + for (const [name, write, linkTo] of writes.flatMap(([n, w]) => [[n, w, target], [`${n} (dangling)`, w, missing]] as const)) { + const link = join(directory, `${name.replace(/\W+/g, "-")}.cc`); + let writer: Promise | undefined; + await withStoreWriteLock(link, async () => { + writer = write(new SingleFileMessagePackBackingStore(link)); + writer.catch(() => undefined); + await sleepFor(150); + await fsp.symlink(linkTo, link); // after the writer's pre-lock look, while it waits + }); + await assert.rejects(writer!, (error: unknown) => error instanceof PackageStoreLinkedError, name); + assert.ok((await fsp.lstat(link)).isSymbolicLink(), `${name}: the link was replaced`); + assert.deepEqual(await readFile(target), before, `${name}: the target changed`); + await assert.rejects(fsp.lstat(missing), { code: "ENOENT" }, `${name}: the dangling target was created`); + await rm(link); // the link is refused before the lock, so remove it to probe the lock + assert.equal(await withStoreWriteLock(link, async () => "free", { timeoutMs: 200 }), "free", `${name}: the lock is held`); + } + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +function noteEnvelope(key: string): CultCacheEnvelope { + return { key, type: "note", payload: documentPayloadFromBytes(Uint8Array.of(0x91, 1)), storedAt: "2026-10-20T00:00:00Z" }; +} + test("the store lock gives up after its wait bound and names the lock path", async () => { const directory = await scratch(); try { From c4a270bc7fedb5a63237f6bee33685ed7d678f7c Mon Sep 17 00:00:00 2001 From: Libby Date: Fri, 13 Nov 2026 07:00:14 +0000 Subject: [PATCH 79/93] Cargo callers on committed-lock crates run with --locked (locked-cargo-callers) (#160) --- .github/workflows/cultnet-interop.yml | 2 +- README.md | 4 ++-- docs/start/README.md | 4 ++-- packages/cultcache-ts/test/cult-cache.test.ts | 2 +- packages/cultmath/README.md | 4 ++-- .../cultmesh-ts/test/interop/write-receipt-interop.test.ts | 2 +- packages/cultnet-rs/README.md | 2 +- packages/cultnet-ts/test/interop/cultnet-interop.test.ts | 2 +- packages/heimdall-stake/README.md | 4 ++-- tests/vectors/document-variants-c2a/old-reader-refusal.sh | 1 + 10 files changed, 14 insertions(+), 13 deletions(-) diff --git a/.github/workflows/cultnet-interop.yml b/.github/workflows/cultnet-interop.yml index 750edc9a..0d193aef 100644 --- a/.github/workflows/cultnet-interop.yml +++ b/.github/workflows/cultnet-interop.yml @@ -138,7 +138,7 @@ jobs: run: cargo test --locked --manifest-path packages/cultnet-rs/Cargo.toml rudp - name: Verify Rust CultMesh runtime-local surface - run: cargo test --manifest-path packages/cultmesh-rs/Cargo.toml + run: cargo test --locked --manifest-path packages/cultmesh-rs/Cargo.toml - name: Verify C# RUDP reliability run: dotnet test tests/GameCult.Networking.Tests/GameCult.Networking.Tests.csproj --filter "FullyQualifiedName~Rudp" diff --git a/README.md b/README.md index b916cabe..a555aa92 100644 --- a/README.md +++ b/README.md @@ -212,8 +212,8 @@ identity-owned C# session after provider route replacement. Rust package tests: ```powershell -cargo test --manifest-path packages/cultcache-rs/Cargo.toml -cargo test --manifest-path packages/cultnet-rs/Cargo.toml +cargo test --locked --manifest-path packages/cultcache-rs/Cargo.toml +cargo test --locked --manifest-path packages/cultnet-rs/Cargo.toml ``` Kotlin package build: diff --git a/docs/start/README.md b/docs/start/README.md index 6a7d6d74..9ca7f499 100644 --- a/docs/start/README.md +++ b/docs/start/README.md @@ -124,7 +124,7 @@ The test imports from `../src/index` where the block reads `@gamecult/cultcache- Same shape with `derive(DatabaseEntry)`: slots are `#[cultcache(key = N)]`, so the `.cc` payload is the slot-indexed array the C# runtime writes. The source is [`packages/cultcache-rs/tests/first_call.rs`](../../packages/cultcache-rs/tests/first_call.rs), run by -`cargo test --test first_call` in `packages/cultcache-rs`; a second test in that file fails if the block +`cargo test --locked --test first_call` in `packages/cultcache-rs`; a second test in that file fails if the block below stops matching the file. The Rust runtime has no name lookup, so the read-back is by key. More: `packages/cultcache-rs/README.md`. @@ -341,7 +341,7 @@ async function firstCall() { ### Rust CultNet first call Source: [`packages/cultnet-rs/tests/first_call.rs`](../../packages/cultnet-rs/tests/first_call.rs), run by -`cargo test --test first_call` in `packages/cultnet-rs`. More: `packages/cultnet-rs/README.md`. +`cargo test --locked --test first_call` in `packages/cultnet-rs`. More: `packages/cultnet-rs/README.md`. ```rust use std::net::{TcpListener, TcpStream}; diff --git a/packages/cultcache-ts/test/cult-cache.test.ts b/packages/cultcache-ts/test/cult-cache.test.ts index d3011ee2..dc1ddb33 100644 --- a/packages/cultcache-ts/test/cult-cache.test.ts +++ b/packages/cultcache-ts/test/cult-cache.test.ts @@ -2106,7 +2106,7 @@ async function readTsInteropStore(file: string): Promise { async function buildInteropPeers(): Promise { if (!(await exists(rustInteropBinary))) { - await execAsync(`"${cargoCommand}" build --quiet --example cultcache_interop`, { + await execAsync(`"${cargoCommand}" build --locked --quiet --example cultcache_interop`, { cwd: cultcacheRsRoot, }); } diff --git a/packages/cultmath/README.md b/packages/cultmath/README.md index b144c56d..6760a1f5 100644 --- a/packages/cultmath/README.md +++ b/packages/cultmath/README.md @@ -147,8 +147,8 @@ CultMath names with tests instead of copied project-local helper drift. ```powershell dotnet build packages/cultmath/src/CultMath/CultMath.csproj dotnet test packages/cultmath/tests/CultMath.Tests/CultMath.Tests.csproj -cargo test --manifest-path packages/cultmath/native/cultmath-core/Cargo.toml -cargo build --release --manifest-path packages/cultmath/native/cultmath-core/Cargo.toml +cargo test --locked --manifest-path packages/cultmath/native/cultmath-core/Cargo.toml +cargo build --locked --release --manifest-path packages/cultmath/native/cultmath-core/Cargo.toml .\packages\cultmath\scripts\build-unity-package.ps1 ``` diff --git a/packages/cultmesh-ts/test/interop/write-receipt-interop.test.ts b/packages/cultmesh-ts/test/interop/write-receipt-interop.test.ts index 092d5ffb..14d9e443 100644 --- a/packages/cultmesh-ts/test/interop/write-receipt-interop.test.ts +++ b/packages/cultmesh-ts/test/interop/write-receipt-interop.test.ts @@ -122,7 +122,7 @@ const rust: Target = { name: "rust", payload: (text) => encode({ text }), async start() { - if (!prebuilt) await execFileAsync(cargo, ["build", "--example", "receipt_interop_server", "--message-format", "short"], { cwd: rustManifestDir }); + if (!prebuilt) await execFileAsync(cargo, ["build", "--locked", "--example", "receipt_interop_server", "--message-format", "short"], { cwd: rustManifestDir }); const metadata = spawnSync(cargo, ["metadata", "--format-version", "1", "--no-deps"], { cwd: rustManifestDir, encoding: "utf8" }); if (metadata.status !== 0) throw new Error(`cargo metadata failed: ${metadata.stderr}`); const exe = join(JSON.parse(metadata.stdout).target_directory, "debug", "examples", `receipt_interop_server${process.platform === "win32" ? ".exe" : ""}`); diff --git a/packages/cultnet-rs/README.md b/packages/cultnet-rs/README.md index c356b97b..69a8e70d 100644 --- a/packages/cultnet-rs/README.md +++ b/packages/cultnet-rs/README.md @@ -30,7 +30,7 @@ knock on the contract before touching the furniture. ## Receipts ```powershell -cargo test +cargo test --locked ``` The initial tests prove: diff --git a/packages/cultnet-ts/test/interop/cultnet-interop.test.ts b/packages/cultnet-ts/test/interop/cultnet-interop.test.ts index d0e732ce..3b8b7bc9 100644 --- a/packages/cultnet-ts/test/interop/cultnet-interop.test.ts +++ b/packages/cultnet-ts/test/interop/cultnet-interop.test.ts @@ -2795,7 +2795,7 @@ async function buildInteropPeers(): Promise { } async function buildRustInteropPeer(): Promise { - rustInteropPeerBuild ??= execFileAsync(cargoCommand, ["build", "--quiet", "--example", "cultnet_interop_peer"], { + rustInteropPeerBuild ??= execFileAsync(cargoCommand, ["build", "--locked", "--quiet", "--example", "cultnet_interop_peer"], { cwd: cultnetRsRoot, }).then(() => undefined); await rustInteropPeerBuild; diff --git a/packages/heimdall-stake/README.md b/packages/heimdall-stake/README.md index 5c8ae095..9b6acb19 100644 --- a/packages/heimdall-stake/README.md +++ b/packages/heimdall-stake/README.md @@ -11,8 +11,8 @@ adversary, storage, and any projection. Every parameter is a placeholder until t ## First call ``` -cargo run --example first_call # prints the Meta example and a splitting table -cargo test +cargo run --locked --example first_call # prints the Meta example and a splitting table +cargo test --locked ``` ```rust diff --git a/tests/vectors/document-variants-c2a/old-reader-refusal.sh b/tests/vectors/document-variants-c2a/old-reader-refusal.sh index bfafa01d..5b9165f0 100755 --- a/tests/vectors/document-variants-c2a/old-reader-refusal.sh +++ b/tests/vectors/document-variants-c2a/old-reader-refusal.sh @@ -111,6 +111,7 @@ probe() { node ../../node_modules/typescript/bin/tsc -p tsconfig.test.json node --test dist-test/test/old-reader-v3.test.js ;; python) PYTHONPATH=packages/cultcache-py/src python3 packages/cultcache-py/tests/test_old_reader_v3.py -v ;; + # The staged tree is the frozen cultcache-rs-v0.3.1 revision, which has no committed Cargo.lock: --locked cannot work here. rust) cd packages/cultcache-rs && cargo test --test old_reader_v3 -- --nocapture ;; *) usage ;; esac From 568a60fa3edafff6488f615c01fdc5db94b410f6 Mon Sep 17 00:00:00 2001 From: Libby Date: Fri, 13 Nov 2026 07:40:21 +0000 Subject: [PATCH 80/93] Late-link tests: the Python leak fails by name, the Rust try entry is pinned (link-late-check-lows) (#161) --- .../cultcache-py/tests/test_store_lock.py | 11 ++++- packages/cultcache-rs/src/lib.rs | 48 +++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/packages/cultcache-py/tests/test_store_lock.py b/packages/cultcache-py/tests/test_store_lock.py index 50b95436..364106bf 100644 --- a/packages/cultcache-py/tests/test_store_lock.py +++ b/packages/cultcache-py/tests/test_store_lock.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import select import shutil import subprocess import sys @@ -18,6 +19,7 @@ from cultcache_py.backing_store import CultCacheEnvelope REPO = Path(__file__).resolve().parents[3] TS_STORE_FILE = REPO / "packages" / "cultcache-ts" / "dist" / "store-file.js" HOLD_SECONDS = 1.5 +HELD_WAIT_SECONDS = 3 # A holder that takes the lock the way the Rust runtime does (an exclusive file lock), not through this package. PY_HOLDER = """ @@ -43,7 +45,14 @@ def envelope(key: str) -> CultCacheEnvelope: def start_holder(argv: list[str]) -> subprocess.Popen[str]: holder = subprocess.Popen(argv, stdout=subprocess.PIPE, text=True) - assert holder.stdout is not None and holder.stdout.readline().strip() == "HELD" + assert holder.stdout is not None + ready, _, _ = select.select([holder.stdout], [], [], HELD_WAIT_SECONDS) # a holder blocked on a leaked lock never prints + if not ready: + holder.kill() + holder.wait() + holder.stdout.close() + raise AssertionError(f"the holder never took {argv[-2]}.lock within {HELD_WAIT_SECONDS} s: an earlier writer leaked the lock") + assert holder.stdout.readline().strip() == "HELD" holder.held_at = time.monotonic() # type: ignore[attr-defined] return holder diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index 9ba800e9..2900a1f7 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -1861,6 +1861,8 @@ impl SingleFileMessagePackBackingStore { fn try_with_exclusive_lock(&self, action: impl FnOnce() -> Result) -> Result> { refuse_linked(&self.path)?; + #[cfg(test)] + run_after_pre_lock_look(); let lock = self.open_lock_file()?; match fs2::FileExt::try_lock_exclusive(&lock) { Ok(()) => {} @@ -4773,6 +4775,20 @@ fn injected_write_fault(step: WriteStep) -> std::io::Result<()> { Ok(()) } +// The one gap a test cannot hold open from outside: a try-lock never waits, so what happens between its pre-lock look and +// the lock is only reachable by running the test's closure there. +#[cfg(test)] +thread_local! { + static AFTER_PRE_LOCK_LOOK: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; +} + +#[cfg(test)] +fn run_after_pre_lock_look() { + if let Some(hook) = AFTER_PRE_LOCK_LOOK.with(|hook| hook.borrow_mut().take()) { + hook(); + } +} + #[cfg(not(test))] #[inline(always)] fn injected_write_fault(_step: WriteStep) -> std::io::Result<()> { @@ -8245,6 +8261,38 @@ mod tests { Ok(()) } + // The try entry has no wait, so the late window is the gap between its look and its lock: a link put there is refused + // once the lock is held, nothing is written through or over it, and the lock is released. + #[cfg(unix)] + #[test] + fn a_link_put_between_the_try_entrys_look_and_its_lock_is_refused_and_the_lock_is_released() -> Result<()> { + let temp = tempfile::tempdir()?; + let target = temp.path().join("target.cc"); + SingleFileMessagePackBackingStore::new(&target).push(&snapshot_envelope("a", b"1"))?; + let before = fs::read(&target)?; + let dangling = temp.path().join("missing.cc"); + let path = temp.path().join("late.cc"); + for link_to in [target.clone(), dangling.clone()] { + let store = SingleFileMessagePackBackingStore::new(&path); + let (link, at) = (link_to.clone(), path.clone()); + AFTER_PRE_LOCK_LOOK.with(|hook| { + *hook.borrow_mut() = Some(Box::new(move || std::os::unix::fs::symlink(&link, &at).expect("put the link"))); + }); + let error = store + .try_compare_exchange_snapshot(&[], &[snapshot_envelope("b", b"2")]) + .expect_err("the late link must be refused"); + assert!(error.downcast_ref::().is_some(), "{error:#}"); + assert!(fs::symlink_metadata(&path)?.file_type().is_symlink(), "the link was replaced"); + assert_eq!(fs::read(&target)?, before, "the target changed"); + assert!(!dangling.exists(), "the dangling target was created"); + let probe = store.open_lock_file()?; + fs2::FileExt::try_lock_exclusive(&probe).map_err(|e| anyhow!("lock still held: {e}"))?; + drop(probe); + fs::remove_file(&path)?; + } + Ok(()) + } + // A write at a store path that is a link is refused: a replace there would swap the link for a regular file and // detach the store from its target. Reads through a live link keep working. #[cfg(unix)] From 6cfd22c786b1db69f315330f1293378e2c42fe11 Mon Sep 17 00:00:00 2001 From: Libby Date: Fri, 13 Nov 2026 08:56:51 +0000 Subject: [PATCH 81/93] Remaining unlocked cargo mentions say --locked (locked-cargo-doc-stragglers) (#162) --- packages/cultnet-rs/examples/cultnet_impair/main.rs | 2 +- packages/cultnet-rs/tests/media_fec_bench.rs | 2 +- packages/cultnet-ts/test/fixtures/idunn-runtime/README.md | 4 ++-- packages/cultnet-ts/test/runtime-presence-health.test.ts | 2 +- packages/heimdall-consent/README.md | 2 +- tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/cultnet-rs/examples/cultnet_impair/main.rs b/packages/cultnet-rs/examples/cultnet_impair/main.rs index c2ebd874..35a2168d 100644 --- a/packages/cultnet-rs/examples/cultnet_impair/main.rs +++ b/packages/cultnet-rs/examples/cultnet_impair/main.rs @@ -7,7 +7,7 @@ //! traffic crosses the same schedule. The schedule is `policy.rs`. //! //! ```text -//! cargo run --release --example cultnet_impair -- \ +//! cargo run --locked --release --example cultnet_impair -- \ //! --listen 0.0.0.0:17890 --upstream 127.0.0.1:17990 \ //! --profile tests/impairment/loss-1pct.toml --seed 424242 --metrics loss-1pct.csv //! ``` diff --git a/packages/cultnet-rs/tests/media_fec_bench.rs b/packages/cultnet-rs/tests/media_fec_bench.rs index 40c9de78..f0279495 100644 --- a/packages/cultnet-rs/tests/media_fec_bench.rs +++ b/packages/cultnet-rs/tests/media_fec_bench.rs @@ -3,7 +3,7 @@ //! Ignored by default; run in release, where the numbers mean something: //! //! ```text -//! cargo test --release --test media_fec_bench -- --ignored --nocapture +//! cargo test --locked --release --test media_fec_bench -- --ignored --nocapture //! ``` //! //! Each line is nanoseconds per call through the public surface, so record diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/README.md b/packages/cultnet-ts/test/fixtures/idunn-runtime/README.md index 7856c78d..86dd821e 100644 --- a/packages/cultnet-ts/test/fixtures/idunn-runtime/README.md +++ b/packages/cultnet-ts/test/fixtures/idunn-runtime/README.md @@ -6,7 +6,7 @@ cultnet-rs only, and Idunn's `IdunnRuntimeActivationLaunch` is that same crate's It needs Linux and a machine-id equal to the `machine-id` file here, because the provider identity is bound to it. On Yggdrasil: - DOCKER_ARGS='--cap-add SYS_ADMIN --security-opt apparmor=unconfined' bash ygg-verify.sh HEAD rust 'cd packages/cultnet-rs && cp /tmp/mid && mount --bind /tmp/mid /etc/machine-id && cargo run -q --example idunn_runtime_fixture /tmp/out' + DOCKER_ARGS='--cap-add SYS_ADMIN --security-opt apparmor=unconfined' bash ygg-verify.sh HEAD rust 'cd packages/cultnet-rs && cp /tmp/mid && mount --bind /tmp/mid /etc/machine-id && cargo run --locked -q --example idunn_runtime_fixture /tmp/out' then copy `/tmp/out` over this directory. `--cap-add SYS_ADMIN` exists only so the bind mount can present the committed `machine-id`, which keeps Yggdrasil's real machine-id out of the repo. Every `presence-*.bin` was accepted by `authenticate_runtime_presence_claim` and correlated by @@ -26,7 +26,7 @@ The generator takes its specification as input, so a consumer needs no patch to specification is command-line flags; `@` splices in a response file (one `--flag value` per line, `#` comments). CultLib's own sets are `fixture.args` here: - cargo run -q --example idunn_runtime_fixture /tmp/out @/fixture.args + cargo run --locked -q --example idunn_runtime_fixture /tmp/out @/fixture.args The consumer keeps its own `.args` file in its repository and runs the same command with the same machine-id bind mount as above. Global flags: `--source-repository`, `--detail`. Each diff --git a/packages/cultnet-ts/test/runtime-presence-health.test.ts b/packages/cultnet-ts/test/runtime-presence-health.test.ts index 2dc4a845..1d7c4fa6 100644 --- a/packages/cultnet-ts/test/runtime-presence-health.test.ts +++ b/packages/cultnet-ts/test/runtime-presence-health.test.ts @@ -2,7 +2,7 @@ // // The vectors below were produced by `cultnet-rs`, not written by hand: // -// cargo run --example runtime_presence_vector -p cultnet-rs +// cargo run --locked --example runtime_presence_vector -p cultnet-rs // // That matters. A fixture authored for both sides at once agrees with itself // and proves nothing; the contract is only real if one fork emits the bytes and diff --git a/packages/heimdall-consent/README.md b/packages/heimdall-consent/README.md index 3782e720..926b7d1a 100644 --- a/packages/heimdall-consent/README.md +++ b/packages/heimdall-consent/README.md @@ -7,7 +7,7 @@ plus the rules that need no store to state (`Answer::is_training_data`, `Answer: (comments 682 and 759). Specification only: no collector, tally, export or tombstone hashing lives here. Entry points: `src/lib.rs`; golden vectors `tests/vectors/consent_golden.csv`, run by `tests/golden.rs` -(`cargo test`). Planned, not live: the collector, the tally, the training export. +(`cargo test --locked`). Planned, not live: the collector, the tally, the training export. ## Where each ruling lands (doc lines at aaeb175) diff --git a/tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs b/tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs index 3a076d16..ad7dcaca 100644 --- a/tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs +++ b/tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs @@ -341,7 +341,7 @@ namespace GameCult.Networking.Tests { var path = Path.Combine(RepoRoot(), "contracts", "cultnet", "interop", "selection-vectors.rs-written.json"); if (!File.Exists(path)) - Assert.Inconclusive($"{path} is missing - run cargo test -p cultnet-rs --test selection with CULTNET_WRITE_VECTORS=1 first."); + Assert.Inconclusive($"{path} is missing - run cargo test --locked -p cultnet-rs --test selection with CULTNET_WRITE_VECTORS=1 first."); var (registry, rows, _) = BuildFixture(); using var document = JsonDocument.Parse(File.ReadAllText(path)); From 8edb2be2f42a8d8aa1ca74d28c11e00133b0cdd7 Mon Sep 17 00:00:00 2001 From: Libby Date: Fri, 13 Nov 2026 10:09:10 +0000 Subject: [PATCH 82/93] Stryker scope extends to GameCult.Caching.Tests; CultElementIds survivors killed or recorded (mutation-testing-scope) (#163) --- docs/mutation-testing.md | 91 ++++++++++- .../ElementIdWalkTests.cs | 151 ++++++++++++++++++ .../stryker-config.json | 11 ++ 3 files changed, 246 insertions(+), 7 deletions(-) create mode 100644 tests/GameCult.Caching.Tests/ElementIdWalkTests.cs create mode 100644 tests/GameCult.Caching.Tests/stryker-config.json diff --git a/docs/mutation-testing.md b/docs/mutation-testing.md index 8db67be5..c6a0f5bd 100644 --- a/docs/mutation-testing.md +++ b/docs/mutation-testing.md @@ -3,7 +3,7 @@ CultLib measures its C# test suites with Stryker.NET, pinned as a repo-local tool in `dotnet-tools.json`. Coverage is added project by project; a project with no `stryker-config.json` next to its test project has not been scoped -yet (see "Follow-ups" below for what's outstanding). +yet (see "Scope and follow-ups" below for what's outstanding). ## Running it @@ -126,14 +126,17 @@ project as precedent for the `vstest` caveat itself - that caveat is `mtp`-runner-specific and documented above for `static readonly` field initializers, and GameCult.Geometry has never actually hit it. -## Follow-ups +## Scope and follow-ups -CultMath.Tests and GameCult.Geometry.Tests are the only projects currently -scoped. GameCult.Geometry's `stryker-config.json` has no `test-runner` -entry: the project is NUnit 4 with `NUnit3TestAdapter` and +CultMath.Tests, GameCult.Geometry.Tests and GameCult.Caching.Tests are the +projects currently scoped. GameCult.Geometry's `stryker-config.json` has no +`test-runner` entry: the project is NUnit 4 with `NUnit3TestAdapter` and `Microsoft.NET.Test.Sdk`, so Stryker's default `vstest` runner applies directly (no `mtp` workaround needed, unlike CultMath.Tests's xunit.v3 -suite). +suite). GameCult.Caching.Tests is the same shape (NUnit, default `vstest`) +and its `stryker-config.json` is driven the same way, by the repo-local +`dotnet-stryker` tool: `dotnet tool restore` once, then `dotnet tool run +dotnet-stryker` from `tests/GameCult.Caching.Tests`. GameCult.Geometry has only ever been run `--since:`-scoped to the cut that added surface nets; a full-project baseline (running mutation against all of @@ -151,7 +154,81 @@ file, anything under `Fixtures/`) loses `--since` line-level scoping for that run; a future cut whose diff is C#-only, against a base commit that already has `stryker-config.json`, should scope correctly. -`GameCult.Caching`, `GameCult.Mesh` (+ `.Quic`, `.Quic.Native`), +### GameCult.Caching.Tests + +`tests/GameCult.Caching.Tests/stryker-config.json` mutates all of +`src/GameCult.Caching` (the project is `GameCult.Caching.csproj`; Standard +mutation level, `break` 0, the same thresholds as Geometry). Caching was +scoped before Networking because its suite is deterministic and quick (1415 +tests, about 40 s) over about 7.8k lines of source, where GameCult.Networking +is about 20k lines and its tests lean on socket timing, so a mutant timing out +there says little about the test. A full Caching run is about 2,900 candidate +mutants (3,602 created, minus 730 that fail to compile) at about 2 s each, +about 1.5 hours; run it detached and read the JSON report, +never as one attached session. + +The first measurement was a narrow pass over the three files that carry the +store-replace and element-id machinery, from `tests/GameCult.Caching.Tests`: + +```bash +dotnet tool run dotnet-stryker \ + -m '**/src/GameCult.Caching/StoreReplace.cs' \ + -m '**/src/GameCult.Caching/CultStoreLinks.cs' \ + -m '**/src/GameCult.Caching/CultElementIds.cs' +``` + +A clean single run of that command tested 243 mutants and scored 86.79% in +9 minutes 55 seconds. The first measurement of it overlapped two Stryker +runs, so its numbers (and the 114 survivors it showed for `CultElementIds.cs`, +where tests that kill a mutant by hand reported `Survived` or `Timeout`) are +not measurements and are not quoted here. Never run two Stryker runs against +one checkout at once, and check `ps` before starting one. A clean single run +on `CultElementIds.cs` alone, after the tests below, tested 228 mutants: 208 +`Killed`, 8 `Timeout`, 12 `Survived`, a score of 94.74%. The 12th survivor +was 321, which the throwing-enumerator case in `ListsOfValuesHoldNoIds` now +kills; the other 11 are equivalent mutants. With that case a clean single run +on `CultElementIds.cs` is 209 `Killed`, 8 `Timeout`, 11 `Survived`, 95.18%. + +Real gaps that run exposed, each now pinned by `ElementIdWalkTests` (and each +failing when its mutant is applied by hand): the order of the id path (the +codec refuses at the first type MessagePack cannot allow, so order is what an +error names first; `Append` to `Prepend` at 163, 167 and 180); the +union-cycle guard in `ConcreteTypes` (248; the mutant is a stack overflow, so +the red is a crashed test host: a depth guard would need production code in +`ConcreteTypes`, since the recursion is private and a stack overflow cannot be +caught by the test, so the regression test is left to crash loudly); a list of values reporting that it holds an +id (`Holds`, 322); `[Key]` read through an override (`KeyOf`, 583); and the +`IsWrittenWhole` and `IsKeyed` predicates (629 to 631 and 636: formatter, +`[MessagePackObject]` and `KeyAsPropertyName` inherited from a base, and the +`[DataContract]` without `[MessagePackObject]` and `||` arms). + +Equivalent survivors in `CultElementIds.cs` (11), each hand-checked against the full +suite; none can be told apart by any test: + +- 142, 251, 636 (second arm), 506 (`IsDefined(UnionAttribute, false)` to + `true`): `UnionAttribute` is `Inherited = false`, so its `inherit` argument + changes nothing. Likewise 630's `DataContractAttribute` `inherit` argument + (`DataContractAttribute` is `Inherited = false`). +- 264, 542: `group.First()` to `FirstOrDefault()` over `GroupBy` groups, which + are never empty. +- 491: `new StringBuilder(bytes.Length * 2)` to `/ 2` changes only the initial + capacity. +- 578: removing the `Nullable.GetUnderlyingType` unwrap in `WalksAsOwner`; + `Classify` unwraps nullables itself, so the unwrap is dead. A cut + candidate, not a test gap. +- 645, 657: `Append(type)` to `Prepend(type)` in `ElementTypeOf` and + `MapTypes`; `type` matches only when it is itself `IEnumerable<>`, + `IDictionary<,>` or `IReadOnlyDictionary<,>`, and none of those has a second + matching interface, so the position of `type` never decides the answer. + +The Windows-only P/Invoke group is exercised only on Windows: its mutants are +`NoCoverage` on Linux and are a target for a Windows-leg mutation run, not a +Linux gap or a finding. `StoreReplace.cs` line 45 is a different case: its +negated `IsOSPlatform(Windows)` mutant is `Survived` on Linux, because the +negated branch calls `ReplaceWindows`, whose `DllNotFound` fallback gives the +same result. + +`GameCult.Mesh` (+ `.Quic`, `.Quic.Native`), `GameCult.Networking` (+ `.WebSockets`), and the non-.NET runtimes (`cultcache-rs/py/ts`, `cultmesh-*`, `cultnet-*`) each still need their own `stryker-config.json` (or ecosystem-equivalent tool: `cargo-mutants` for diff --git a/tests/GameCult.Caching.Tests/ElementIdWalkTests.cs b/tests/GameCult.Caching.Tests/ElementIdWalkTests.cs new file mode 100644 index 00000000..bd6cb903 --- /dev/null +++ b/tests/GameCult.Caching.Tests/ElementIdWalkTests.cs @@ -0,0 +1,151 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.Serialization; +using GameCult.Caching; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Caching.Tests +{ + // The id walk's own contracts, one mutant-sized fact at a time: the order of the id path, a union that names itself, a list of + // values never counting as an id, and the attribute predicates that decide whether a type is walked or written whole. + public class ElementIdWalkTests + { + // The codec asks MessagePack about the id path in order and refuses at the first type it cannot allow, so the order is what + // an error names first: the document, then the collection types from the outermost in, then the element and its arms. + [Test] + public void TheIdPathListsCollectionsFromTheOutermostIn() + { + Assert.That(CultElementIds.Inspect(typeof(MsMapMap)).IdPath, Is.EqualTo(new[] + { + typeof(MsMapMap), typeof(Dictionary>>), + typeof(Dictionary>), typeof(List), typeof(ElementIdEngineTests.EIded) + }), "maps nested in maps"); + Assert.That(CultElementIds.Inspect(typeof(MsListDeep)).IdPath, Is.EqualTo(new[] + { + typeof(MsListDeep), typeof(List>>), typeof(List>), + typeof(List), typeof(ElementIdEngineTests.EIded) + }), "lists nested in lists"); + } + + // ConcreteTypes stops at a type it has already expanded, so a union that lists its own base is finished, not a stack overflow. + [Test] + public void AUnionThatNamesItselfIsWalkedOnce() + { + var survey = CultElementIds.Inspect(typeof(MsSelfHolder)); + Assert.That(survey.Problems, Is.Empty); + Assert.That(survey.IdPath, Is.EquivalentTo(new[] { typeof(MsSelfHolder), typeof(List), typeof(MsSelfBase), typeof(MsSelfArm) })); + } + + // Value lists hold no ids however they are nested, and a root that is itself a value list holds none either. + [Test] + public void ListsOfValuesHoldNoIds() + { + var values = new MsValues { Numbers = { 1 }, Tags = { "a" }, Grid = { new List { 1 } }, Map = { ["k"] = new List { 2 } } }; + Assert.That(CultElementIds.Holds(typeof(MsValues), values), Is.False); + Assert.That(CultElementIds.Holds(typeof(List), new List { 1, 2 }), Is.False); + Assert.That(CultElementIds.Holds(typeof(List>), new List> { new() { 1 } }), Is.False); + Assert.That(CultElementIds.Holds(typeof(List), new List { "a" }), Is.False); + Assert.That(CultElementIds.Holds(typeof(MsBoom), new MsBoom()), Is.False, "a sequence of values is not enumerated"); + } + + // A nullable struct is walked as the struct it wraps; a nullable value is not walked at all. + [Test] + public void ANullableKeyedStructWalksAsItsOwner() + { + Assert.That(CultElementIds.WalksAsOwner(typeof(MsKeyedStruct)), Is.True); + Assert.That(CultElementIds.WalksAsOwner(typeof(MsKeyedStruct?)), Is.True); + Assert.That(CultElementIds.WalksAsOwner(typeof(int?)), Is.False); + Assert.That(CultElementIds.WalksAsOwner(typeof(int)), Is.False); + } + + // [Key] is read through an override, so an element whose id property overrides a keyed base member is still an id with a key. + [Test] + public void AKeyOnTheBaseOfAnOverrideCountsForTheOverride() + { + Assert.That(CultElementIds.Inspect(typeof(MsOverrideHolder)).Problems, Is.Empty); + Assert.That(CultElementIds.Inspect(typeof(MsOverrideHolder)).IdPath, Does.Contain(typeof(MsOverrideEl))); + } + + // A type is walked by key when it is [MessagePackObject] or [Union], read through its bases for the former; it is one whole + // value when it declares its own formatter, is [DataContract] without being [MessagePackObject], or is keyed by name. + [Test] + public void KeyedTypesWalkAndWholeTypesDoNot() + { + Assert.That(CultElementIds.WalksAsOwner(typeof(MsMpBase)), Is.True, "[MessagePackObject]"); + Assert.That(CultElementIds.WalksAsOwner(typeof(MsUBase)), Is.True, "[Union]"); + Assert.That(CultElementIds.WalksAsOwner(typeof(MsMpDerived)), Is.True, "[MessagePackObject] inherited"); + Assert.That(CultElementIds.WalksAsOwner(typeof(MsPlain)), Is.False, "[Key] members without [MessagePackObject] or [Union]"); + Assert.That(CultElementIds.WalksAsOwner(typeof(MsUArm)), Is.True, "an arm that is itself [MessagePackObject]"); + + Assert.That(CultElementIds.WalksAsOwner(typeof(MsDc)), Is.False, "[DataContract] alone"); + Assert.That(CultElementIds.WalksAsOwner(typeof(MsDcMp)), Is.True, "[DataContract] beside [MessagePackObject]"); + Assert.That(CultElementIds.WalksAsOwner(typeof(MsDcOverMpBase)), Is.True, "[DataContract] over an inherited [MessagePackObject]"); + + Assert.That(CultElementIds.WalksAsOwner(typeof(MsPropName)), Is.False, "KeyAsPropertyName"); + Assert.That(CultElementIds.WalksAsOwner(typeof(MsPropNameDerived)), Is.False, "KeyAsPropertyName inherited"); + Assert.That(CultElementIds.WalksAsOwner(typeof(MsStringKey)), Is.False, "a string [Key]"); + + Assert.That(CultElementIds.WalksAsOwner(typeof(MsFormattedBase)), Is.False, "[MessagePackFormatter]"); + Assert.That(CultElementIds.WalksAsOwner(typeof(MsFormattedDerived)), Is.False, "[MessagePackFormatter] inherited"); + } + + [MessagePackObject] public class MsMapMap { [Key(0)] public Dictionary>> M { get; set; } = new(); } + [MessagePackObject] public class MsListDeep { [Key(0)] public List>> G { get; set; } = new(); } + + [Union(0, typeof(MsSelfBase)), Union(1, typeof(MsSelfArm))] public abstract class MsSelfBase { } + [MessagePackObject] public class MsSelfArm : MsSelfBase { [Key(0)] [CultElementId] public string Id { get; set; } = ""; } + [MessagePackObject] public class MsSelfHolder { [Key(0)] public List Items { get; set; } = new(); } + + [MessagePackObject] + public class MsValues + { + [Key(0)] public List Numbers { get; set; } = new(); + [Key(1)] public List Tags { get; set; } = new(); + [Key(2)] public List> Grid { get; set; } = new(); + [Key(3)] public Dictionary> Map { get; set; } = new(); + } + + public sealed class MsBoom : IEnumerable + { + public IEnumerator GetEnumerator() => throw new InvalidOperationException("enumerated"); + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + } + + [MessagePackObject] public struct MsKeyedStruct { [Key(0)] public int X; } + + [MessagePackObject] + public class MsOverrideBase + { + [Key(0)] [CultElementId] public virtual string Id { get; set; } = ""; + } + + [MessagePackObject] + public class MsOverrideEl : MsOverrideBase + { + public override string Id { get; set; } = ""; + } + + [MessagePackObject] public class MsOverrideHolder { [Key(0)] public List Items { get; set; } = new(); } + + [MessagePackObject] public class MsMpBase { [Key(0)] public int A { get; set; } } + public class MsMpDerived : MsMpBase { } + public class MsPlain { [Key(0)] public int A { get; set; } } + [Union(0, typeof(MsUArm))] public abstract class MsUBase { } + [MessagePackObject] public class MsUArm : MsUBase { [Key(0)] public int A { get; set; } } + + [DataContract] public class MsDc { [DataMember] public int A { get; set; } } + [DataContract] [MessagePackObject] public class MsDcMp { [Key(0)] public int A { get; set; } } + [DataContract] public class MsDcOverMpBase : MsMpBase { } + + [MessagePackObject(keyAsPropertyName: true)] public class MsPropName { public int A { get; set; } } + public class MsPropNameDerived : MsPropName { } + [MessagePackObject] public class MsStringKey { [Key("a")] public int A { get; set; } } + + public sealed class MsNoFormatter { } + [MessagePackFormatter(typeof(MsNoFormatter))] [MessagePackObject] public class MsFormattedBase { [Key(0)] public int A { get; set; } } + [MessagePackObject] public class MsFormattedDerived : MsFormattedBase { [Key(1)] public int B { get; set; } } + } +} diff --git a/tests/GameCult.Caching.Tests/stryker-config.json b/tests/GameCult.Caching.Tests/stryker-config.json new file mode 100644 index 00000000..ac366203 --- /dev/null +++ b/tests/GameCult.Caching.Tests/stryker-config.json @@ -0,0 +1,11 @@ +{ + "stryker-config": { + "project": "GameCult.Caching.csproj", + "mutate": [ + "**/src/GameCult.Caching/**/*.cs" + ], + "mutation-level": "Standard", + "reporters": [ "progress", "html", "json" ], + "thresholds": { "high": 80, "low": 60, "break": 0 } + } +} From c229b86fde24d4d088c70b54668b4b447f4fef2a Mon Sep 17 00:00:00 2001 From: Libby Date: Fri, 13 Nov 2026 11:16:15 +0000 Subject: [PATCH 83/93] cultcache-rs: lock-file open waits through a Windows sharing violation (#164) --- packages/cultcache-rs/src/lib.rs | 132 +++++++++++++++++++++++++++++-- 1 file changed, 124 insertions(+), 8 deletions(-) diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index 2900a1f7..b37582d7 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -1863,7 +1863,9 @@ impl SingleFileMessagePackBackingStore { refuse_linked(&self.path)?; #[cfg(test)] run_after_pre_lock_look(); - let lock = self.open_lock_file()?; + let Some(lock) = self.try_open_lock_file()? else { + return Ok(None); + }; match fs2::FileExt::try_lock_exclusive(&lock) { Ok(()) => {} Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => return Ok(None), @@ -1881,19 +1883,31 @@ impl SingleFileMessagePackBackingStore { release_lock(lock, refuse_linked(&self.path).and_then(|()| action())) } + /// Opens the lock file, waiting through a Windows sharing violation the way the lock itself waits. fn open_lock_file(&self) -> Result { + let lock_path = self.prepare_lock_dir()?; + retry_while_busy( + || open_lock_once(&lock_path), + is_sharing_violation, + || std::thread::sleep(std::time::Duration::from_millis(5)), + ) + .with_context(|| format!("failed to open {}", lock_path.display())) + } + + /// The single-attempt open for the try entry: a sharing violation is `None`, the same answer as a held lock. + fn try_open_lock_file(&self) -> Result> { + let lock_path = self.prepare_lock_dir()?; + open_unless_busy(|| open_lock_once(&lock_path), is_sharing_violation) + .with_context(|| format!("failed to open {}", lock_path.display())) + } + + fn prepare_lock_dir(&self) -> Result { let lock_path = self.lock_path(); if let Some(parent) = lock_path.parent() { fs::create_dir_all(parent) .with_context(|| format!("failed to create {}", parent.display()))?; } - OpenOptions::new() - .create(true) - .read(true) - .truncate(false) - .write(true) - .open(&lock_path) - .with_context(|| format!("failed to open {}", lock_path.display())) + Ok(lock_path) } fn lock_path(&self) -> PathBuf { @@ -4548,6 +4562,53 @@ impl ReplaceKernel for WindowsKernel { } } +fn open_lock_once(lock_path: &Path) -> std::io::Result { + OpenOptions::new() + .create(true) + .read(true) + .truncate(false) + .write(true) + .open(lock_path) +} + +/// Windows ERROR_SHARING_VIOLATION (raw os error 32): another handle holds the lock file open without sharing. On Unix +/// errno 32 is EPIPE and is never a busy file. +#[cfg(windows)] +fn is_sharing_violation(error: &std::io::Error) -> bool { + error.raw_os_error() == Some(32) +} + +#[cfg(not(windows))] +fn is_sharing_violation(_error: &std::io::Error) -> bool { + false +} + +/// Repeats `open` while it fails busy, sleeping between tries and without a bound, as the lock waits; any other error surfaces. +fn retry_while_busy( + mut open: impl FnMut() -> std::io::Result, + busy: impl Fn(&std::io::Error) -> bool, + mut sleep: impl FnMut(), +) -> std::io::Result { + loop { + match open() { + Err(error) if busy(&error) => sleep(), + other => return other, + } + } +} + +/// One attempt: `None` when the open fails busy, any other error surfaces. +fn open_unless_busy( + open: impl FnOnce() -> std::io::Result, + busy: impl Fn(&std::io::Error) -> bool, +) -> std::io::Result> { + match open() { + Ok(file) => Ok(Some(file)), + Err(error) if busy(&error) => Ok(None), + Err(error) => Err(error), + } +} + #[cfg(test)] mod replace_with_tests { use super::{replace_with, ReplaceKernel}; @@ -8261,6 +8322,61 @@ mod tests { Ok(()) } + fn busy_error() -> std::io::Error { + std::io::Error::from_raw_os_error(32) + } + + fn is_busy(error: &std::io::Error) -> bool { + error.raw_os_error() == Some(32) + } + + #[test] + fn the_lock_open_waits_through_busy_failures_then_opens() { + let (mut tries, mut sleeps) = (0, 0); + let opened = retry_while_busy( + || { + tries += 1; + if tries <= 4 { Err(busy_error()) } else { Ok(tries) } + }, + is_busy, + || sleeps += 1, + ); + assert_eq!(opened.unwrap(), 5); + assert_eq!(sleeps, 4); + } + + #[test] + fn the_lock_open_surfaces_a_non_busy_error_at_once() { + let (mut tries, mut sleeps) = (0, 0); + let error = retry_while_busy::<()>( + || { + tries += 1; + Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied)) + }, + is_busy, + || sleeps += 1, + ) + .expect_err("a non-busy error must surface"); + assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied); + assert_eq!((tries, sleeps), (1, 0)); + } + + #[test] + fn the_try_open_maps_a_sharing_violation_to_none_without_retrying() { + let mut tries = 0; + let opened = open_unless_busy::<()>( + || { + tries += 1; + Err(busy_error()) + }, + is_busy, + ); + assert!(opened.unwrap().is_none()); + assert_eq!(tries, 1); + let other = open_unless_busy::<()>(|| Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied)), is_busy); + assert!(other.is_err()); + } + // The try entry has no wait, so the late window is the gap between its look and its lock: a link put there is refused // once the lock is held, nothing is written through or over it, and the lock is released. #[cfg(unix)] From 64f4d291479bd3e6170bec742004a1196f3b7b15 Mon Sep 17 00:00:00 2001 From: Libby Date: Fri, 13 Nov 2026 12:16:41 +0000 Subject: [PATCH 84/93] Mutation testing doc: name the revision of each narrow-pass score, restate the full-run rate (#165) --- docs/mutation-testing.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/mutation-testing.md b/docs/mutation-testing.md index c6a0f5bd..9136b667 100644 --- a/docs/mutation-testing.md +++ b/docs/mutation-testing.md @@ -163,8 +163,8 @@ scoped before Networking because its suite is deterministic and quick (1415 tests, about 40 s) over about 7.8k lines of source, where GameCult.Networking is about 20k lines and its tests lean on socket timing, so a mutant timing out there says little about the test. A full Caching run is about 2,900 candidate -mutants (3,602 created, minus 730 that fail to compile) at about 2 s each, -about 1.5 hours; run it detached and read the JSON report, +mutants (3,602 created, minus 730 that fail to compile) at 1.25 to 1.6 s +each, so about 1 to 1.3 hours; run it detached and read the JSON report, never as one attached session. The first measurement was a narrow pass over the three files that carry the @@ -177,8 +177,10 @@ dotnet tool run dotnet-stryker \ -m '**/src/GameCult.Caching/CultElementIds.cs' ``` -A clean single run of that command tested 243 mutants and scored 86.79% in -9 minutes 55 seconds. The first measurement of it overlapped two Stryker +A clean single run of that command tests 243 to 265 mutants (the count moves with the revision) and scored 85.28% on +568a60fa, before any test of this scope was added; 86.79% on d38b5728, with +the first batch of tests; and 87.17% on 76d9e7d1, with the throwing-enumerator +case, in about 10 minutes. The first measurement of it overlapped two Stryker runs, so its numbers (and the 114 survivors it showed for `CultElementIds.cs`, where tests that kill a mutant by hand reported `Survived` or `Timeout`) are not measurements and are not quoted here. Never run two Stryker runs against From f9c72a2fd2ef42cf488eadd90b040e0126c04919 Mon Sep 17 00:00:00 2001 From: Libby Date: Fri, 13 Nov 2026 12:43:17 +0000 Subject: [PATCH 85/93] cultcache-rs: pin try entry never waits and error 32 never retried off Windows (tests only) (#166) --- packages/cultcache-rs/src/lib.rs | 45 ++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index b37582d7..e7d56c10 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -8377,6 +8377,51 @@ mod tests { assert!(other.is_err()); } + // The try entry must answer at once while another handle holds the lock. The attempt runs on a thread and its + // answer is awaited with a timeout, so an entry that waits fails this test by timeout instead of hanging the suite. + #[cfg(unix)] + #[test] + fn the_try_entry_returns_at_once_while_the_lock_is_held() -> Result<()> { + let temp = tempfile::tempdir()?; + let path = temp.path().join("held.cc"); + let store = SingleFileMessagePackBackingStore::new(&path); + let holder = store.open_lock_file()?; + fs2::FileExt::lock_exclusive(&holder)?; + let (sender, receiver) = std::sync::mpsc::channel(); + let attempt = SingleFileMessagePackBackingStore::new(&path); + std::thread::spawn(move || { + let _ = sender.send(attempt.try_compare_exchange_snapshot(&[], &[snapshot_envelope("b", b"2")])); + }); + let outcome = receiver + .recv_timeout(std::time::Duration::from_secs(3)) + .map_err(|_| anyhow!("the try entry waited on a held lock instead of returning"))?; + assert!(matches!(outcome?, TryCompareExchangeSnapshotOutcome::LockContended)); + assert!(!path.exists(), "the contended attempt wrote"); + let again = store.open_lock_file()?; + assert!(fs2::FileExt::try_lock_exclusive(&again).is_err(), "the held lock was disturbed"); + fs2::FileExt::unlock(&holder)?; + Ok(()) + } + + // Off Windows errno 32 is EPIPE, never a busy file: the predicate is false and the wait surfaces it on the first try. + #[cfg(not(windows))] + #[test] + fn error_32_is_not_busy_off_windows_and_surfaces_at_once() { + assert!(!is_sharing_violation(&busy_error())); + let (mut tries, mut sleeps) = (0, 0); + let error = retry_while_busy::<()>( + || { + tries += 1; + Err(busy_error()) + }, + is_sharing_violation, + || sleeps += 1, + ) + .expect_err("error 32 must surface off Windows"); + assert_eq!(error.raw_os_error(), Some(32)); + assert_eq!((tries, sleeps), (1, 0)); + } + // The try entry has no wait, so the late window is the gap between its look and its lock: a link put there is refused // once the lock is held, nothing is written through or over it, and the lock is released. #[cfg(unix)] From 3d333e8466b57ebd6e139cf1f6dcfa221a428ac1 Mon Sep 17 00:00:00 2001 From: Libby Date: Fri, 13 Nov 2026 15:40:35 +0000 Subject: [PATCH 86/93] Caching: CultCache.Update, update-with-retry as the golden-path write (golden-path-write-api) (#168) --- docs/start/README.md | 33 +- src/GameCult.Caching/CultCache.cs | 78 +++ src/GameCult.Caching/CultInspectorModel.cs | 2 +- src/GameCult.Caching/CultVariants.cs | 5 + src/GameCult.Caching/README.md | 25 +- .../GameCult.Caching.Tests/FirstCallTests.cs | 31 +- .../ReadmeHappyPathTests.cs | 81 +++ .../GameCult.Caching.Tests/UpdateCallTests.cs | 54 ++ tests/GameCult.Caching.Tests/UpdateTests.cs | 515 ++++++++++++++++++ 9 files changed, 806 insertions(+), 18 deletions(-) create mode 100644 tests/GameCult.Caching.Tests/ReadmeHappyPathTests.cs create mode 100644 tests/GameCult.Caching.Tests/UpdateCallTests.cs create mode 100644 tests/GameCult.Caching.Tests/UpdateTests.cs diff --git a/docs/start/README.md b/docs/start/README.md index 9ca7f499..e9dc0b3f 100644 --- a/docs/start/README.md +++ b/docs/start/README.md @@ -16,7 +16,7 @@ this page. | | CultCache | CultMath | Geometry | CultNet | CultMesh | | --- | --- | --- | --- | --- | --- | -| **C#** | [first call, tested](#c-first-call) | [package](../../packages/cultmath/README.md); snippet not yet | [package](../../src/GameCult.Geometry/README.md); snippet not yet | [first call, tested](#c-cultnet-first-call); [package](../../src/GameCult.Networking) | [quickstart](../../src/GameCult.Mesh/docs/durable-node-quickstart.md); run by `scripts/verify-eve-getting-started.ps1` | +| **C#** | [first call, tested](#c-first-call); [update, tested](#c-update-with-retry) | [package](../../packages/cultmath/README.md); snippet not yet | [package](../../src/GameCult.Geometry/README.md); snippet not yet | [first call, tested](#c-cultnet-first-call); [package](../../src/GameCult.Networking) | [quickstart](../../src/GameCult.Mesh/docs/durable-node-quickstart.md); run by `scripts/verify-eve-getting-started.ps1` | | **TypeScript** | [first call, tested](#typescript-first-call) | not yet | not yet | [first call, tested](#typescript-cultnet-first-call); [package](../../packages/cultnet-ts/README.md) | [package](../../packages/cultmesh-ts/README.md); snippet not yet; [recipe: two-client .io game](io-game.md), tested with two live clients, browser page not yet run | | **Rust** | [first call, tested](#rust-first-call) | [native core](../../packages/cultmath/native/cultmath-core); snippet not yet | not yet | [first call, tested](#rust-cultnet-first-call); [package](../../packages/cultnet-rs/README.md), [examples](../../packages/cultnet-rs/examples) | [package](../../packages/cultmesh-rs); snippet not yet | | **Python** | [first call, tested](#python-first-call) | not yet | not yet | [first call, tested](#python-cultnet-first-call); [package](../../packages/cultnet-py/README.md) | [package](../../packages/cultmesh-py/README.md); snippet not yet | @@ -70,6 +70,37 @@ public static class FirstCall `FirstCall.Run` is the call; a second test in that file fails if the block above stops matching it. More: `src/GameCult.Caching/README.md`. +## C# update with retry + +Change a record you may not be the only one changing. `Update` reads the record, hands your function a private +copy, and commits the result only if the record is still the one it read. If another writer got there first, it +reads again and runs your function again, up to five times. The source is +[`tests/GameCult.Caching.Tests/UpdateCallTests.cs`](../../tests/GameCult.Caching.Tests/UpdateCallTests.cs); the +snippet reuses `Item` from the first call. + +```csharp +using GameCult.Caching; +using GameCult.Caching.MessagePack; + +public static class UpdateCall +{ + public static CultUpdateResult Restock(string path, CultRecordKey key) + { + var cache = CultCacheMessagePack.Create(path); + return cache.Update(key, item => + { + item ??= new Item { Name = "Potion", Category = "Consumable" }; + item.Value += 10; + return item; + }); + } +} +``` + +The write is durable when `Update` returns, with no `PushAll`. A lost race re-runs the function, so keep it free of +side effects; when the attempts run out the result says `LostRace` instead of throwing. `UpsertAsync` plus +`PushAll` is the blind opt-out: last writer wins. + ## TypeScript first call Same shape in TypeScript: the document is a name, a schema id and a `parse`-able schema (zod here), diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index 469fc7af..dc0c8b99 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -2052,6 +2052,29 @@ namespace GameCult.Caching Contended } + public enum CultUpdateOutcome + { + Updated, + Declined, + LostRace + } + + /// + /// What did. A lost race is a value, not an exception. is the instance the + /// cache holds at the key when Update returns: the written one for Updated, null when the key is absent. + /// + public readonly struct CultUpdateResult where T : class + { + public CultUpdateResult(CultUpdateOutcome outcome, T? document) + { + Outcome = outcome; + Document = document; + } + + public CultUpdateOutcome Outcome { get; } + public T? Document { get; } + } + public sealed class CultCommitRequest { internal CultCommitRequest( @@ -2563,6 +2586,7 @@ namespace GameCult.Caching return UpsertAsync(document, handle); } + // A blind write, last-writer-wins per key; Update is the conditional golden path. // Refused with CultSchemaConflictException when the key holds a record stored under an id no registered type declares. public Task> UpsertAsync(T document, CultRecordHandle? handle = null) { @@ -2639,6 +2663,7 @@ namespace GameCult.Caching /// /// Commits the staged batch. Returns false when a condition failed: nothing was written, changed in memory, or published. + /// Without Expect a commit is blind; retries a conditional one. /// /// /// A commit that succeeds and then fails in a journal or observer still stands and is published, and the writer @@ -2656,6 +2681,59 @@ namespace GameCult.Caching return Land(stage, wait: false); } + /// + /// Reads the record at , applies to a private copy of it, and commits the result only + /// if the record is still the one that was read; when another writer got there first it re-reads and runs + /// again, up to times. The write is durable when Update returns. + /// + /// + /// may run up to times. It gets a private copy (null when the key holds + /// nothing), which it may change and return. It must not write to the cache or have other side effects. Return null to write + /// nothing. A store with staged blind writes refuses Update until it is flushed, as it refuses any conditional commit. The cache + /// needs a codec to copy with. Running out of attempts returns ; every other failure throws. + /// + public CultUpdateResult Update(CultRecordKey key, Func apply, int maxAttempts = 5) where T : class + { + if (apply == null) throw new ArgumentNullException(nameof(apply)); + if (maxAttempts < 1) throw new ArgumentOutOfRangeException(nameof(maxAttempts), maxAttempts, "At least one attempt is required."); + var codec = _codec ?? throw new InvalidOperationException("This cache has no codec; create it with CultCacheMessagePack.Create."); + for (var attempt = 1; ; attempt++) + { + var (version, copy) = Held(() => + { + if (!_entries.TryGetValue(key.Value, out var held)) + return ((string?)null, (T?)null); + if (held.Variant != null || held.Document is not T) + throw new InvalidOperationException($"Record {key.Value} is a variant or not a {typeof(T).Name}; Update cannot copy it."); + return (held.StoredVersion, (T?)codec.Clone(held.Document, held.Descriptor.DocumentType)); + }); + var next = apply(copy); + if (next == null) + return new CultUpdateResult(CultUpdateOutcome.Declined, Get(key)); + CultCommitOutcome outcome; + try + { + outcome = Land(batch => + { + batch.Expected.Add((key, version)); + batch.Upsert(next, new CultRecordHandle(key)); + }, wait: true); + } + catch (CultWriteConflictException conflict) when (conflict.InnerException == null) + { + outcome = CultCommitOutcome.Mismatch; + } + + if (outcome == CultCommitOutcome.Committed) + return new CultUpdateResult(CultUpdateOutcome.Updated, next); + if (outcome != CultCommitOutcome.Mismatch) + throw new InvalidOperationException($"Update of {key.Value} was refused: {outcome}."); + Held(() => { Home(next.GetType())?.PullAll(); return true; }); + if (attempt >= maxAttempts) + return new CultUpdateResult(CultUpdateOutcome.LostRace, Get(key)); + } + } + public CultRecordHandle? TryGetHandle(T document) { if (document == null) throw new ArgumentNullException(nameof(document)); diff --git a/src/GameCult.Caching/CultInspectorModel.cs b/src/GameCult.Caching/CultInspectorModel.cs index 7aa6ab74..60dec974 100644 --- a/src/GameCult.Caching/CultInspectorModel.cs +++ b/src/GameCult.Caching/CultInspectorModel.cs @@ -352,7 +352,7 @@ namespace GameCult.Caching } } - public object Clone(object document, Type type) => _deserialize(type, _serialize(document, type, type)); + public object Clone(object document, Type type) => _codec.Clone(document, type); public CultInspectorEdit BeginEdit(CultStoredDocument record) { diff --git a/src/GameCult.Caching/CultVariants.cs b/src/GameCult.Caching/CultVariants.cs index 48909c19..e5c28bbf 100644 --- a/src/GameCult.Caching/CultVariants.cs +++ b/src/GameCult.Caching/CultVariants.cs @@ -29,6 +29,11 @@ namespace GameCult.Caching // (document payload, slot and encoded value pairs) public Func>, byte[]> Overlay { get; } + // The one copy of a document: its encoding, decoded again. Update's private argument and CultInspectorModel.Clone share it. + internal object Clone(object document, Type type) => + Deserialize(type, type, Serialize(document, type, type)) + ?? throw new InvalidOperationException($"The codec returned null for {type.FullName}."); + /// /// Decodes a document payload read from a store file, as a cache load decodes it: the ids a load mints are minted from the key /// and their path, so every reader of one store mints the same ids, and a payload a cache write would refuse is refused here too. diff --git a/src/GameCult.Caching/README.md b/src/GameCult.Caching/README.md index 6c76f5cd..dffce80a 100644 --- a/src/GameCult.Caching/README.md +++ b/src/GameCult.Caching/README.md @@ -13,6 +13,7 @@ Cacheable models are plain classes annotated with cache intent. using GameCult.Caching; using MessagePack; +[MessagePackObject] [CultDocument("gamecult.item_data", "gamecult.item_data.v1")] public sealed class ItemData { @@ -131,18 +132,33 @@ using GameCult.Caching; using GameCult.Caching.MessagePack; var cache = await CultCacheMessagePack.OpenAsync("Data.msgpack"); +var key = new CultRecordKey("potion"); +// The golden path: read, change a private copy, commit only if the record is unchanged, re-run on a lost race. +// Durable when it returns; no flush. +var result = cache.Update(key, item => +{ + item ??= new ItemData { Name = "Potion", Sku = "POT-1", Category = "Consumable" }; + item.Value += 10; + return item; +}); + +// The blind opt-out: last writer wins per key. Name the key, or a new key claims the same unique Sku and is refused. var handle = await cache.UpsertAsync(new ItemData { Name = "Potion", Sku = "POT-1", Category = "Consumable", Value = 50 -}); +}, new CultRecordHandle(key)); await cache.FlushAsync(); ``` +`Update` may run your function more than once, so it must have no side effects. Returning null writes nothing, and a +lost race that outlasts `maxAttempts` comes back as `CultUpdateOutcome.LostRace`. `UpsertAsync` plus `FlushAsync` never +checks what it overwrites. + If you want the explicit assembly rite, the lower-level `CultCache` + `SingleFileMessagePackBackingStore` path is still there. The helper is just the blessed front door. @@ -294,13 +310,10 @@ Useful surfaces: - `cache.IsDirty` - `store.IsDirty` - `cache.Document(key)` -- `cache.Soa()` -- `cache.UpsertAsync(document)` +- `cache.Update(key, apply)` +- `cache.UpsertAsync(document)` (blind write) - `cache.FlushAllBackingStores()` - `cache.FlushAsync()` -- `cache.FlushBackingStore(store)` -- `cache.PrepareForReloadOrShutdown()` -- `cache.PrepareForReloadOrShutdownAsync()` - `cache.FlushAttachedStoresOnDispose` - `store.FlushOnDispose` diff --git a/tests/GameCult.Caching.Tests/FirstCallTests.cs b/tests/GameCult.Caching.Tests/FirstCallTests.cs index 8fad1229..d8febc49 100644 --- a/tests/GameCult.Caching.Tests/FirstCallTests.cs +++ b/tests/GameCult.Caching.Tests/FirstCallTests.cs @@ -62,17 +62,28 @@ namespace GameCult.Caching.Tests } [Test] - public void DocsShowTheTestedSnippet() - { - var source = File.ReadAllText(SourcePath()); - const string begin = "// snippet:begin\n"; - var start = source.IndexOf(begin, StringComparison.Ordinal) + begin.Length; - var snippet = source[start..source.IndexOf("// snippet:end\n", StringComparison.Ordinal)]; - var docs = File.ReadAllText(Path.Combine(Path.GetDirectoryName(SourcePath())!, "..", "..", "docs", "start", "README.md")); - Assert.That(docs.Replace("\r\n", "\n"), Does.Contain("```csharp\n" + snippet + "```"), - "docs/start/README.md no longer shows tests/GameCult.Caching.Tests/FirstCallTests.cs verbatim"); - } + public void DocsShowTheTestedSnippet() => StartDocs.AssertShowsSnippet(SourcePath()); static string SourcePath([System.Runtime.CompilerServices.CallerFilePath] string path = "") => path; } + + // docs/start/README.md shows each tested snippet verbatim: the region between snippet:begin and snippet:end of its source file. + internal static class StartDocs + { + public static void AssertShowsSnippet(string sourcePath) => + AssertShowsSnippet(sourcePath, Path.Combine("..", "..", "docs", "start", "README.md")); + + // The same check against another document, its path relative to the directory of sourcePath. The snippet there is the + // statements of a block, so it need not open the fence (the block's usings come first). + public static void AssertShowsSnippet(string sourcePath, string docsRelativePath, bool wholeBlock = true) + { + var source = File.ReadAllText(sourcePath).Replace("\r\n", "\n"); + const string begin = "// snippet:begin\n"; + var start = source.IndexOf(begin, StringComparison.Ordinal) + begin.Length; + var snippet = source[start..source.IndexOf("// snippet:end\n", StringComparison.Ordinal)]; + var docs = File.ReadAllText(Path.Combine(Path.GetDirectoryName(sourcePath)!, docsRelativePath)); + Assert.That(docs.Replace("\r\n", "\n"), Does.Contain(wholeBlock ? "```csharp\n" + snippet + "```" : snippet), + $"{docsRelativePath} no longer shows {Path.GetFileName(sourcePath)} verbatim"); + } + } } diff --git a/tests/GameCult.Caching.Tests/ReadmeHappyPathTests.cs b/tests/GameCult.Caching.Tests/ReadmeHappyPathTests.cs new file mode 100644 index 00000000..de2dabb4 --- /dev/null +++ b/tests/GameCult.Caching.Tests/ReadmeHappyPathTests.cs @@ -0,0 +1,81 @@ +#nullable enable +using System; +using System.IO; +using System.Threading.Tasks; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Caching.Tests +{ + // The Happy Path of src/GameCult.Caching/README.md, run as written: its statements are the region between snippet:begin and + // snippet:end below, and ReadmeShowsTheTestedSnippet fails when the README stops showing them verbatim. + public class ReadmeHappyPathTests + { + [CultDocument("gamecult.item_data", "gamecult.item_data.v1")] + [MessagePackObject] + public sealed class ItemData + { + [Key(0)] [CultName] public string Name = string.Empty; + [Key(1)] [CultIndex] public string Sku = string.Empty; + [Key(2)] public string Category = string.Empty; + [Key(3)] public int Value; + } + + private static async Task HappyPath() + { +// snippet:begin +var cache = await CultCacheMessagePack.OpenAsync("Data.msgpack"); +var key = new CultRecordKey("potion"); + +// The golden path: read, change a private copy, commit only if the record is unchanged, re-run on a lost race. +// Durable when it returns; no flush. +var result = cache.Update(key, item => +{ + item ??= new ItemData { Name = "Potion", Sku = "POT-1", Category = "Consumable" }; + item.Value += 10; + return item; +}); + +// The blind opt-out: last writer wins per key. Name the key, or a new key claims the same unique Sku and is refused. +var handle = await cache.UpsertAsync(new ItemData +{ + Name = "Potion", + Sku = "POT-1", + Category = "Consumable", + Value = 50 +}, new CultRecordHandle(key)); + +await cache.FlushAsync(); +// snippet:end + return cache; + } + + [Test] + public async Task HappyPathRunsAsWritten() + { + var directory = Path.Combine(Path.GetTempPath(), $"readme-happy-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + var previous = Environment.CurrentDirectory; + try + { + Environment.CurrentDirectory = directory; // the README opens a relative path + using var cache = await HappyPath(); + Assert.That(cache.Get(new CultRecordKey("potion"))!.Value, Is.EqualTo(50)); + Assert.That(cache.GetByIndex("Sku", "POT-1"), Is.Not.Null); + } + finally + { + Environment.CurrentDirectory = previous; + Directory.Delete(directory, recursive: true); + } + } + + [Test] + public void ReadmeShowsTheTestedSnippet() => + StartDocs.AssertShowsSnippet(SourcePath(), Path.Combine("..", "..", "src", "GameCult.Caching", "README.md"), wholeBlock: false); + + static string SourcePath([System.Runtime.CompilerServices.CallerFilePath] string path = "") => path; + } +} diff --git a/tests/GameCult.Caching.Tests/UpdateCallTests.cs b/tests/GameCult.Caching.Tests/UpdateCallTests.cs new file mode 100644 index 00000000..d9a8b31e --- /dev/null +++ b/tests/GameCult.Caching.Tests/UpdateCallTests.cs @@ -0,0 +1,54 @@ +#nullable enable +using System; +using System.IO; +using NUnit.Framework; + +// snippet:begin +using GameCult.Caching; +using GameCult.Caching.MessagePack; + +public static class UpdateCall +{ + public static CultUpdateResult Restock(string path, CultRecordKey key) + { + var cache = CultCacheMessagePack.Create(path); + return cache.Update(key, item => + { + item ??= new Item { Name = "Potion", Category = "Consumable" }; + item.Value += 10; + return item; + }); + } +} +// snippet:end + +namespace GameCult.Caching.Tests +{ + // The C# update snippet that docs/start/README.md shows verbatim; it reuses FirstCallTests' Item. + public class UpdateCallTests + { + [Test] + public void RestockTwiceReadsTwenty() + { + var path = Path.Combine(Path.GetTempPath(), $"update-call-{Guid.NewGuid():N}.cc"); + try + { + var key = new CultRecordKey("potion"); + Assert.That(UpdateCall.Restock(path, key).Outcome, Is.EqualTo(CultUpdateOutcome.Updated)); + Assert.That(UpdateCall.Restock(path, key).Outcome, Is.EqualTo(CultUpdateOutcome.Updated)); + + using var reader = CultCacheMessagePack.Create(path); + Assert.That(reader.Get(key)!.Value, Is.EqualTo(20)); + } + finally + { + File.Delete(path); + } + } + + [Test] + public void DocsShowTheTestedSnippet() => StartDocs.AssertShowsSnippet(SourcePath()); + + static string SourcePath([System.Runtime.CompilerServices.CallerFilePath] string path = "") => path; + } +} diff --git a/tests/GameCult.Caching.Tests/UpdateTests.cs b/tests/GameCult.Caching.Tests/UpdateTests.cs new file mode 100644 index 00000000..0dd96bad --- /dev/null +++ b/tests/GameCult.Caching.Tests/UpdateTests.cs @@ -0,0 +1,515 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; +using NUnit.Framework; +using R3; + +namespace GameCult.Caching.Tests +{ + // CultCache.Update: read the content version, apply a pure function to a private copy, commit conditionally, re-run on a lost + // race (variants:ruling:write-set-content-version-declared-ownership; variants:question:update-apply-copy). + public class UpdateTests + { + private static readonly CultRecordKey Key = new("counter"); + private static readonly CultDocumentRegistry Registry = + CultDocumentRegistry.ForTypes(new[] { typeof(UpdateCounter), typeof(UpdateTally), typeof(UpdateGear) }); + + private string _directory = string.Empty; + + [SetUp] + public void CreateDirectory() + { + _directory = Path.Combine(Path.GetTempPath(), $"cultlib-update-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_directory); + } + + [TearDown] + public void DeleteDirectory() + { + if (Directory.Exists(_directory)) + Directory.Delete(_directory, recursive: true); + } + + private string PathOf(string name) => Path.Combine(_directory, name); + + private static CultCache Open(string path, bool directory, bool codec = true) + { + var cache = codec ? new CultCache(Registry, CultCacheMessagePack.CreateCodec(Registry)) : new CultCache(Registry); + cache.AddBackingStore(directory ? new DirectoryMessagePackBackingStore(path) : new SingleFileMessagePackBackingStore(path)); + return cache; + } + + private static void Seed(string path, bool directory, int value) + { + using var cache = Open(path, directory); + Assert.That(cache.Commit(batch => batch.Upsert(new UpdateCounter { Name = "counter", Value = value }, new CultRecordHandle(Key))), Is.True); + } + + private static void Rival(CultCache cache, int value) => + Assert.That(cache.Commit(batch => batch.Upsert(new UpdateCounter { Name = "counter", Value = value }, new CultRecordHandle(Key))), Is.True); + + private static int StoredValue(string path, bool directory) + { + using var reader = Open(path, directory); + return reader.Get(Key)!.Value; + } + + private static UpdateCounter Plus(UpdateCounter? counter, int by) + { + counter ??= new UpdateCounter { Name = "counter" }; + counter.Value += by; + return counter; + } + + [TestCase(false)] + [TestCase(true)] + public void TwoWritersUpdatingLoseNoIncrement(bool directory) + { + const int n = 100; + var path = PathOf("race.cc"); + Seed(path, directory, 0); + using var a = Open(path, directory); + using var b = Open(path, directory); + var applied = 0; + + var racers = new[] { a, b }.Select(cache => Task.Run(() => + { + var results = new List(); + for (var i = 0; i < n; i++) + results.Add(cache.Update(Key, counter => + { + Interlocked.Increment(ref applied); + return Plus(counter, 1); + }, maxAttempts: 200).Outcome); + return results; + })).ToArray(); + Assert.That(Task.WaitAll(racers, TimeSpan.FromMinutes(2)), Is.True, "the racers did not finish"); + + Assert.That(racers.SelectMany(racer => racer.Result), Is.All.EqualTo(CultUpdateOutcome.Updated)); + Assert.That(StoredValue(path, directory), Is.EqualTo(2 * n)); + Assert.That(applied, Is.GreaterThan(2 * n), "the writers never actually raced"); + } + + [Test] + public void LostAttemptRerunsApplyOnTheFreshValue() + { + var path = PathOf("rerun.cc"); + Seed(path, directory: false, 1); + using var cache = Open(path, false); + using var rival = Open(path, false); + var seen = new List(); + + var result = cache.Update(Key, counter => + { + seen.Add(counter!.Value); + if (seen.Count == 1) + Rival(rival, 100); + return Plus(counter, 1); + }); + + Assert.That(seen, Is.EqualTo(new[] { 1, 100 })); + Assert.That(result.Outcome, Is.EqualTo(CultUpdateOutcome.Updated)); + Assert.That(result.Document!.Value, Is.EqualTo(101)); + Assert.That(StoredValue(path, false), Is.EqualTo(101)); + } + + [Test] + public void ExhaustedAttemptsReturnLostRace() + { + var path = PathOf("lost.cc"); + Seed(path, false, 1); + using var cache = Open(path, false); + using var rival = Open(path, false); + var calls = 0; + + var result = cache.Update(Key, counter => + { + Rival(rival, 100 * ++calls); + return Plus(counter, 1); + }, maxAttempts: 3); + + Assert.That(calls, Is.EqualTo(3)); + Assert.That(result.Outcome, Is.EqualTo(CultUpdateOutcome.LostRace)); + Assert.That(result.Document!.Value, Is.EqualTo(300)); + Assert.That(StoredValue(path, false), Is.EqualTo(300), "the store holds the rival's last write"); + } + + [Test] + public void ApplyGetsAPrivateCopy() + { + var path = PathOf("copy.cc"); + Seed(path, false, 1); + using var cache = Open(path, false); + using var rival = Open(path, false); + var published = new List(); + using var subscription = cache.Watch().Subscribe(change => published.Add(change.Document!.Value)); + var arguments = new List(); + var calls = 0; + + var result = cache.Update(Key, counter => + { + calls++; + arguments.Add(counter!.Value); + Assert.That(counter, Is.Not.SameAs(cache.Get(Key)), "apply was handed the held instance"); + counter.Value += 1000; + Assert.That(cache.Get(Key)!.Value, Is.Not.EqualTo(counter.Value), "the change reached the cache before any commit"); + if (calls == 1) + Rival(rival, 5); + return counter; + }); + + Assert.That(arguments, Is.EqualTo(new[] { 1, 5 }), "the second call saw the first call's change"); + Assert.That(result.Outcome, Is.EqualTo(CultUpdateOutcome.Updated)); + Assert.That(published, Does.Not.Contain(1001), "an uncommitted value was published"); + Assert.That(published.Count(value => value == 1005), Is.EqualTo(1)); + Assert.That(StoredValue(path, false), Is.EqualTo(1005)); + } + + [Test] + public void NullFromApplyDeclines() + { + var path = PathOf("decline.cc"); + Seed(path, false, 7); + var before = File.ReadAllBytes(path); + using var cache = Open(path, false); + var changes = 0; + using var subscription = cache.Watch().Subscribe(_ => changes++); + + var result = cache.Update(Key, _ => null); + + Assert.That(result.Outcome, Is.EqualTo(CultUpdateOutcome.Declined)); + Assert.That(result.Document!.Value, Is.EqualTo(7)); + Assert.That(File.ReadAllBytes(path), Is.EqualTo(before)); + Assert.That(changes, Is.EqualTo(0)); + } + + [TestCase(false)] + [TestCase(true)] + public void AbsentKeyCreatesOnceUnderRace(bool directory) + { + var path = PathOf("create.cc"); + using var a = Open(path, directory); + using var b = Open(path, directory); + var started = new Barrier(2); + + var racers = new[] { a, b }.Select(cache => Task.Run(() => + { + started.SignalAndWait(); + return cache.Update(Key, counter => Plus(counter, 1)).Outcome; + })).ToArray(); + Assert.That(Task.WaitAll(racers, TimeSpan.FromMinutes(1)), Is.True); + + Assert.That(racers.Select(racer => racer.Result), Is.All.EqualTo(CultUpdateOutcome.Updated)); + Assert.That(StoredValue(path, directory), Is.EqualTo(2)); + } + + [Test] + public void RefusalsAreNotRetried() + { + var path = PathOf("refuse.cc"); + Seed(path, false, 1); + var before = File.ReadAllBytes(path); + var calls = 0; + Func apply = counter => { calls++; return Plus(counter, 1); }; + + using (var codecLess = Open(path, false, codec: false)) + Assert.That(() => codecLess.Update(Key, apply), Throws.InvalidOperationException); + using (var cache = Open(path, false)) + { + Assert.That(() => cache.Update(Key, apply, maxAttempts: 0), Throws.TypeOf()); + Assert.That(() => cache.Update(Key, null!), Throws.TypeOf()); + Assert.That(() => cache.Update(Key, _ => new UpdateTally { Name = "counter" }), Throws.InvalidOperationException); + } + + using (var cache = Open(path, false)) + { + cache.Commit(batch => + { + batch.Upsert(new UpdateGear { Name = "gear", Power = 1 }, new CultRecordHandle(new CultRecordKey("gear"))); + }); + cache.Commit(batch => batch.UpsertVariant(new CultRecordKey("gear-big"), new CultRecordKey("gear"), + new[] { cache.Override(nameof(UpdateGear.Name), "gear big"), cache.Override(nameof(UpdateGear.Power), 9) })); + } + + before = File.ReadAllBytes(path); + var gearCalls = 0; + using (var cache = Open(path, false)) + Assert.That(() => cache.Update(new CultRecordKey("gear-big"), gear => { gearCalls++; return gear; }), Throws.InvalidOperationException); + Assert.That(gearCalls, Is.EqualTo(0), "apply ran for a variant"); + Assert.That(calls, Is.EqualTo(0), "apply ran for a refused call"); + Assert.That(File.ReadAllBytes(path), Is.EqualTo(before)); + + using (var cache = Open(path, false)) + { + cache.UpsertAsync(new UpdateTally { Name = "staged" }).GetAwaiter().GetResult(); + Assert.That(() => cache.Update(Key, apply), Throws.InvalidOperationException.With.Message.Contains("flush before a conditional commit")); + Assert.That(calls, Is.LessThanOrEqualTo(1)); + } + + Assert.That(File.ReadAllBytes(path), Is.EqualTo(before), "a refusal wrote"); + } + + [Test] + public void VariantStoreConflictIsALostAttempt() + { + var path = PathOf("variant.cc"); + Seed(path, false, 1); + using (var seed = Open(path, false)) + { + seed.Commit(batch => batch.Upsert(new UpdateGear { Name = "gear", Power = 1 }, new CultRecordHandle(new CultRecordKey("gear")))); + seed.Commit(batch => batch.UpsertVariant(new CultRecordKey("gear-big"), new CultRecordKey("gear"), + new[] { seed.Override(nameof(UpdateGear.Name), "gear big"), seed.Override(nameof(UpdateGear.Power), 9) })); + } + + using var cache = Open(path, false); + using (var rival = Open(path, false)) + Assert.That(rival.Commit(batch => batch.Upsert(new UpdateTally { Name = "other" }, new CultRecordHandle(new CultRecordKey("other")))), Is.True); + var calls = 0; + + var result = cache.Update(Key, counter => { calls++; return Plus(counter, 1); }); + + Assert.That(result.Outcome, Is.EqualTo(CultUpdateOutcome.Updated)); + Assert.That(calls, Is.EqualTo(2), "the conflict was not a lost attempt that re-ran apply"); + Assert.That(StoredValue(path, false), Is.EqualTo(2)); + } + + private static CultPersistedStoreSnapshot ReadSnapshot(string path) => + CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); + + // A reload that fails inside a conflict is a read failure, not a lost race: Update lets it propagate and never re-runs apply. + // Here the gear a variant is based on has gone foreign in the file, so the reload the conflict owes cannot drop it. + [Test] + public void ConflictWhoseReloadFailedPropagates() + { + var path = PathOf("reload-failed.cc"); + Seed(path, false, 1); + using var cache = Open(path, false); + cache.Commit(batch => batch.Upsert(new UpdateGear { Name = "gear", Power = 1 }, new CultRecordHandle(new CultRecordKey("gear")))); + cache.Commit(batch => batch.UpsertVariant(new CultRecordKey("gear-big"), new CultRecordKey("gear"), + new[] { cache.Override(nameof(UpdateGear.Name), "gear big"), cache.Override(nameof(UpdateGear.Power), 9) })); + const string foreignId = "sha256:00000000000000000000000000000000000000000000000000000000000050de"; + var snapshot = ReadSnapshot(path); + snapshot.Records.Single(record => record.Key == "gear").SchemaId = foreignId; + snapshot.SchemaCatalog = snapshot.SchemaCatalog.Append(new CultSchemaCatalogEntry + { + SchemaId = foreignId, SchemaName = "tests.update_foreign", SchemaVersion = "tests.update_foreign.v1", + ContentHash = "foreign", CanonicalSchemaJson = "{}" + }).ToArray(); + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + var calls = 0; + + var conflict = Assert.Throws(() => cache.Update(Key, counter => { calls++; return Plus(counter, 1); }))!; + + Assert.That(conflict.InnerException, Is.Not.Null, "the reload that failed is the cause"); + Assert.That(calls, Is.EqualTo(1), "a failed reload was retried"); + } + + [TestCase(false)] + [TestCase(true)] + public void RivalWritingAndRestoringTheValueStillRerunsApply(bool directory) + { + var path = PathOf("aba.cc"); + Seed(path, directory, 1); + using var cache = Open(path, directory); + using var rival = Open(path, directory); + var seen = new List(); + + var result = cache.Update(Key, counter => + { + seen.Add(counter!.Value); + if (seen.Count == 1) + { + Rival(rival, 5); + Rival(rival, 1); + } + return Plus(counter, 1); + }); + + Assert.That(seen, Is.EqualTo(new[] { 1, 1 }), "the content version missed A-B-A"); + Assert.That(result.Outcome, Is.EqualTo(CultUpdateOutcome.Updated)); + Assert.That(StoredValue(path, directory), Is.EqualTo(2)); + } + + [TestCase(false)] + [TestCase(true)] + public void RivalRemovalIsSeenAsNull(bool directory) + { + var path = PathOf("removal.cc"); + Seed(path, directory, 1); + using var cache = Open(path, directory); + using var rival = Open(path, directory); + var seen = new List(); + + var result = cache.Update(Key, counter => + { + seen.Add(counter?.Value); + if (seen.Count == 1) + rival.Commit(batch => batch.Remove(Key)); + return Plus(counter, 1); + }); + + Assert.That(seen, Is.EqualTo(new int?[] { 1, null })); + Assert.That(result.Outcome, Is.EqualTo(CultUpdateOutcome.Updated)); + Assert.That(StoredValue(path, directory), Is.EqualTo(1)); + } + + [Test] + public void ExhaustedAttemptsReturnLostRaceInADirectoryStore() + { + var path = PathOf("lost-dir.cc"); + Seed(path, true, 1); + using var cache = Open(path, true); + using var rival = Open(path, true); + var calls = 0; + + var result = cache.Update(Key, counter => + { + Rival(rival, 100 * ++calls); + return Plus(counter, 1); + }, maxAttempts: 2); + + Assert.That(calls, Is.EqualTo(2)); + Assert.That(result.Outcome, Is.EqualTo(CultUpdateOutcome.LostRace)); + Assert.That(result.Document!.Value, Is.EqualTo(200)); + Assert.That(StoredValue(path, true), Is.EqualTo(200)); + } + + [Test] + public void OneAttemptMeansOneApplyAndNonPositiveThrows() + { + var path = PathOf("one.cc"); + Seed(path, false, 1); + using var cache = Open(path, false); + using var rival = Open(path, false); + var calls = 0; + + var result = cache.Update(Key, counter => { calls++; Rival(rival, 50); return Plus(counter, 1); }, maxAttempts: 1); + + Assert.That(calls, Is.EqualTo(1)); + Assert.That(result.Outcome, Is.EqualTo(CultUpdateOutcome.LostRace)); + Assert.That(() => cache.Update(Key, counter => counter, maxAttempts: -1), Throws.TypeOf()); + Assert.That(calls, Is.EqualTo(1)); + } + + [TestCase(false)] + [TestCase(true)] + public void UpdatedDocumentIsTheHeldInstanceAndOwnWritesCauseNoRetry(bool directory) + { + var path = PathOf("own.cc"); + Seed(path, directory, 1); + using var cache = Open(path, directory); + cache.UpsertAsync(new UpdateCounter { Name = "counter", Value = 10 }, new CultRecordHandle(Key)).GetAwaiter().GetResult(); + cache.FlushAsync().GetAwaiter().GetResult(); + var calls = 0; + + var first = cache.Update(Key, counter => { calls++; return Plus(counter, 1); }); + var second = cache.Update(Key, counter => { calls++; return Plus(counter, 1); }); + + Assert.That(calls, Is.EqualTo(2), "the cache's own writes made a later Update retry"); + Assert.That(first.Document, Is.Not.Null); + Assert.That(second.Document, Is.SameAs(cache.Get(Key)), "Updated returned a copy, not the held instance"); + Assert.That(StoredValue(path, directory), Is.EqualTo(12)); + } + + [Test] + public void ApplyRunsOutsideTheCacheGate() + { + var path = PathOf("gate.cc"); + Seed(path, false, 1); + using var cache = Open(path, false); + var reached = false; + + cache.Update(Key, counter => + { + reached = Task.Run(() => cache.Get(Key)).Wait(TimeSpan.FromSeconds(10)); + return Plus(counter, 1); + }); + + Assert.That(reached, Is.True, "another thread could not read the cache while apply ran"); + } + + [TestCase(false)] + [TestCase(true)] + public void ContendedLedgerStoredValueEqualsUpdatedCount(bool directory) + { + var path = PathOf("ledger.cc"); + Seed(path, directory, 0); + var caches = Enumerable.Range(0, 3).Select(_ => Open(path, directory)).ToArray(); + try + { + var tasks = caches.Select(cache => Task.Run(() => + { + var updated = 0; + for (var i = 0; i < 25; i++) + if (cache.Update(Key, counter => Plus(counter, 1)).Outcome == CultUpdateOutcome.Updated) + updated++; + return updated; + })).ToArray(); + Assert.That(Task.WaitAll(tasks, TimeSpan.FromMinutes(2)), Is.True); + + Assert.That(StoredValue(path, directory), Is.EqualTo(tasks.Sum(task => task.Result)), "an Updated outcome was lost or a LostRace wrote"); + } + finally + { + foreach (var cache in caches) + cache.Dispose(); + } + } + + [Test] + public void InMemoryCacheUpdatesUnderThreadRace() + { + using var cache = new CultCache(Registry, CultCacheMessagePack.CreateCodec(Registry)); + cache.Commit(batch => batch.Upsert(new UpdateCounter { Name = "counter" }, new CultRecordHandle(Key))); + + var threads = Enumerable.Range(0, 2).Select(_ => Task.Run(() => + { + for (var i = 0; i < 100; i++) + Assert.That(cache.Update(Key, counter => Plus(counter, 1), maxAttempts: 200).Outcome, Is.EqualTo(CultUpdateOutcome.Updated)); + })).ToArray(); + Assert.That(Task.WaitAll(threads, TimeSpan.FromMinutes(1)), Is.True); + + Assert.That(cache.Get(Key)!.Value, Is.EqualTo(200)); + } + + [CultDocument("tests.update_counter", "tests.update_counter.v1")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class UpdateCounter + { + [Key(0)] + [CultName] + public string Name = string.Empty; + + [Key(1)] + public int Value; + } + + [CultDocument("tests.update_tally", "tests.update_tally.v1")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class UpdateTally + { + [Key(0)] + [CultName] + public string Name = string.Empty; + } + + [CultDocument("tests.update_gear", "tests.update_gear.v1")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class UpdateGear + { + [Key(0)] + [CultName] + public string Name = string.Empty; + + [Key(1)] + public int Power; + } + } +} From 70f21da06a189f5c6b9d6bb19a3ab90c845dc524 Mon Sep 17 00:00:00 2001 From: Libby Date: Fri, 13 Nov 2026 18:49:46 +0000 Subject: [PATCH 87/93] cultcache-rs: expose a store's schema catalog (versions) to consumers (#167) --- packages/cultcache-rs/src/lib.rs | 43 +++++ packages/cultcache-rs/tests/schema_catalog.rs | 165 ++++++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 packages/cultcache-rs/tests/schema_catalog.rs diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index e7d56c10..8fccdf97 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -480,6 +480,39 @@ struct PersistedSchemaCatalogEntry( Vec, ); +/// One entry of a store file's schema catalog, read-only: the scalar fields every runtime publishes for a schema id. The members and +/// the canonical schema JSON the file also carries are not exposed. `schema_version` is the version string the writer published +/// (C# `CultSchemaCatalogEntry.SchemaVersion`). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SchemaCatalogEntry { + pub schema_id: String, + pub schema_name: String, + pub schema_version: String, + pub content_hash: String, + pub compatible_schema_ids: Vec, +} + +impl From<&PersistedSchemaCatalogEntry> for SchemaCatalogEntry { + fn from(entry: &PersistedSchemaCatalogEntry) -> Self { + Self { + schema_id: entry.0.clone(), + schema_name: entry.1.clone(), + schema_version: entry.2.clone(), + content_hash: entry.3.clone(), + compatible_schema_ids: entry.5.clone(), + } + } +} + +/// The catalog entry a record's schema id names, as the decoder resolves it: the first entry that has the id as its own, else the +/// first entry that lists it as compatible; `None` when the catalog publishes neither. +pub fn resolve_schema_entry<'a>(catalog: &'a [SchemaCatalogEntry], schema_id: &str) -> Option<&'a SchemaCatalogEntry> { + catalog + .iter() + .find(|entry| entry.schema_id == schema_id) + .or_else(|| catalog.iter().find(|entry| entry.compatible_schema_ids.iter().any(|id| id == schema_id))) +} + #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] struct PersistedSchemaCatalogMember( u32, @@ -1697,6 +1730,16 @@ impl SingleFileMessagePackBackingStore { self.read_all_unlocked() } + /// The schema catalog the store file publishes, read under the same shared lock [`CacheBackingStore::pull_all`] takes. An absent + /// file and a legacy envelope array have no catalog and give an empty one. Resolve a record's `schema_id` against it with + /// [`resolve_schema_entry`]. + pub fn pull_schema_catalog(&self) -> Result> { + if is_absent(&self.path)? && is_absent(&self.lock_path())? { + return Ok(Vec::new()); + } + self.with_shared_lock(|| Ok(self.read_store_unlocked()?.catalog.iter().map(SchemaCatalogEntry::from).collect())) + } + fn write_all_unlocked(&self, entries: &[CultCacheEnvelope]) -> Result<()> { let failed = |kind| CultCacheStoreWriteFailed { path: self.path.clone(), diff --git a/packages/cultcache-rs/tests/schema_catalog.rs b/packages/cultcache-rs/tests/schema_catalog.rs new file mode 100644 index 00000000..36ec3bed --- /dev/null +++ b/packages/cultcache-rs/tests/schema_catalog.rs @@ -0,0 +1,165 @@ +//! A consumer of a single-file store reads the schema version a record was published under from the store's catalog, through the +//! public surface only: `pull_all`, `pull_schema_catalog` and `resolve_schema_entry` +//! (`cultlib-gaps:follow_up:rs-snapshot-schema-version`). + +use std::path::PathBuf; + +use cultcache_rs::{ + CacheBackingStore, CultCache, DatabaseEntry, SchemaCatalogEntry, SingleFileMessagePackBackingStore, resolve_schema_entry, +}; + +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "tests.sc_one", schema = "tests.sc_one")] +struct One { + #[cultcache(key = 0)] + text: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "tests.sc_two", schema = "tests.sc_two")] +struct Two { + #[cultcache(key = 0)] + text: String, +} + +fn vectors(folder: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../tests/vectors").join(folder) +} + +/// The version a store publishes for each record, in key order: `(key, schema name, schema version)`. +fn versions(store: &SingleFileMessagePackBackingStore) -> Vec<(String, String, String)> { + let catalog = store.pull_schema_catalog().unwrap(); + let mut rows: Vec<_> = store + .pull_all() + .unwrap() + .iter() + .map(|envelope| { + let id = envelope.schema_id.as_deref().unwrap(); + let entry = resolve_schema_entry(&catalog, id).unwrap_or_else(|| panic!("no entry for {id}")); + (envelope.key.clone(), entry.schema_name.clone(), entry.schema_version.clone()) + }) + .collect(); + rows.sort(); + rows +} + +fn copy(from: PathBuf) -> (tempfile::TempDir, SingleFileMessagePackBackingStore) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("store.cc"); + std::fs::copy(from, &path).unwrap(); + (dir, SingleFileMessagePackBackingStore::new(&path)) +} + +#[test] +fn a_consumer_reads_the_version_of_each_type_a_rust_store_holds() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("store.cc"); + let mut cache = CultCache::new(); + cache.register_entry_type::().unwrap(); + cache.register_entry_type::().unwrap(); + cache.add_backing_store(SingleFileMessagePackBackingStore::new(&path), ["tests.sc_one", "tests.sc_two"]).unwrap(); + cache.pull_all_backing_stores().unwrap(); + cache.put("a", &One { text: "1".into() }).unwrap(); + cache.put("b", &Two { text: "2".into() }).unwrap(); + drop(cache); + + let consumer = SingleFileMessagePackBackingStore::new(&path); + assert_eq!( + versions(&consumer), + vec![ + ("a".to_string(), "tests.sc_one".to_string(), "tests.sc_one.v1".to_string()), + ("b".to_string(), "tests.sc_two".to_string(), "tests.sc_two.v1".to_string()), + ] + ); +} + +#[test] +fn an_id_only_an_entry_lists_as_compatible_resolves_to_that_entry() { + let (_dir, store) = copy(vectors("document-variants-c2a/readability/compatible-id-only-v3.bin")); + let catalog = store.pull_schema_catalog().unwrap(); + assert_eq!(catalog.len(), 1); + assert_eq!(catalog[0].schema_id, "vectors.item.next"); + assert_eq!(catalog[0].compatible_schema_ids, vec!["vectors.item".to_string()]); + let entry = resolve_schema_entry(&catalog, "vectors.item").unwrap(); + assert_eq!(entry.schema_id, "vectors.item.next"); + assert_eq!(entry.schema_version, "vectors.item.v1"); + assert_eq!(resolve_schema_entry(&catalog, "vectors.nowhere"), None); +} + +#[test] +fn an_id_an_entry_owns_resolves_to_its_owner_not_to_an_entry_listing_it() { + for vector in ["own-id-over-compatible-v3.bin", "compatible-before-owner-v3.bin"] { + let (_dir, store) = copy(vectors("document-variants-c2a/readability").join(vector)); + let catalog = store.pull_schema_catalog().unwrap(); + assert_eq!(catalog.len(), 2, "{vector}"); + let entry = resolve_schema_entry(&catalog, "vectors.item").unwrap(); + assert_eq!((entry.schema_id.as_str(), entry.schema_version.as_str()), ("vectors.item", "vectors.item.v1"), "{vector}"); + } +} + +#[test] +fn a_legacy_array_and_an_absent_file_publish_no_catalog() { + let (_dir, store) = copy(vectors("document-variants-c2a/readability/legacy-envelopes.bin")); + assert!(!store.pull_all().unwrap().is_empty()); + assert_eq!(store.pull_schema_catalog().unwrap(), Vec::::new()); + + let dir = tempfile::tempdir().unwrap(); + let absent = SingleFileMessagePackBackingStore::new(dir.path().join("none.cc")); + assert_eq!(absent.pull_schema_catalog().unwrap(), Vec::::new()); + assert!(!dir.path().join("none.cc").exists() && !dir.path().join("none.cc.lock").exists(), "reading an absent store creates nothing"); +} + +// `tests/vectors/key-identity/foreign-store.cc` is written by the C# reference: two types, each published with its version. +#[test] +fn a_store_the_csharp_reference_wrote_gives_its_versions() { + let (_dir, store) = copy(vectors("key-identity/foreign-store.cc")); + assert_eq!( + versions(&store), + vec![ + ("j".to_string(), "tests.ki_alpha".to_string(), "tests.ki_alpha.v1".to_string()), + ("k".to_string(), "tests.ki_gamma".to_string(), "tests.ki_gamma.v1".to_string()), + ] + ); + let catalog = store.pull_schema_catalog().unwrap(); + assert_eq!(catalog[0].content_hash, "sha256:02f0c8b7ea07c11705c1365fc29136ada08c8076209cacfb8d5526229461ce7a"); +} + +type Entry = (&'static str, &'static str, &'static str, &'static str, &'static str, Vec<&'static str>, Vec<()>); + +// A hand-built v3 store whose versions are not the `.v1` the writers above publish, so a version read from anywhere but the +// version slot cannot pass. `shared` is listed by two entries (the first lister wins); `id.c` is owned by one entry and listed by +// an earlier one (the owner wins). +#[test] +fn distinct_versions_come_through_with_the_decoders_resolution() { + let catalog: Vec = vec![ + ("id.a", "N.a", "9.9.9", "hash-a", "{}", vec!["id.a", "shared"], vec![]), + ("id.b", "N.b", "7.0", "hash-b", "{}", vec!["id.b", "shared", "id.c"], vec![]), + ("id.c", "N.c", "3", "hash-c", "{}", vec!["id.c"], vec![]), + ]; + let record = |key: &str, id: &str| (key.to_string(), id.to_string(), "2026-11-13T00:00:00Z".to_string(), b"x".to_vec()); + let records = vec![record("r1", "shared"), record("r2", "id.c"), record("r3", "id.b"), record("r4", "id.a")]; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("store.cc"); + std::fs::write(&path, rmp_serde::to_vec(&("cultcache.store.v3", catalog, records)).unwrap()).unwrap(); + + let store = SingleFileMessagePackBackingStore::new(&path); + let listed = store.pull_schema_catalog().unwrap(); + assert_eq!(listed.iter().map(|e| e.schema_version.as_str()).collect::>(), vec!["9.9.9", "7.0", "3"]); + assert_eq!(listed[1].content_hash, "hash-b"); + assert_eq!(listed[1].compatible_schema_ids, vec!["id.b", "shared", "id.c"]); + let got: Vec<(String, String, String)> = { + let mut rows: Vec<_> = store + .pull_all() + .unwrap() + .iter() + .map(|e| { + let entry = resolve_schema_entry(&listed, e.schema_id.as_deref().unwrap()).unwrap(); + (e.key.clone(), entry.schema_name.clone(), entry.schema_version.clone()) + }) + .collect(); + rows.sort(); + rows + }; + let row = |k: &str, n: &str, v: &str| (k.to_string(), n.to_string(), v.to_string()); + assert_eq!(got, vec![row("r1", "N.a", "9.9.9"), row("r2", "N.c", "3"), row("r3", "N.b", "7.0"), row("r4", "N.a", "9.9.9")]); +} From 395d64e1153a821d11d882b175f40fa60fb1e071 Mon Sep 17 00:00:00 2001 From: Libby Date: Fri, 13 Nov 2026 19:28:57 +0000 Subject: [PATCH 88/93] cultcache-ts: update-with-retry (update-with-retry-ts) (#169) --- docs/start/README.md | 26 +- packages/cultcache-ts/CHANGELOG.md | 9 +- packages/cultcache-ts/README.md | 6 +- packages/cultcache-ts/src/browser.ts | 2 + packages/cultcache-ts/src/cult-cache.ts | 315 ++++++--- .../single-file-messagepack-backing-store.ts | 95 ++- packages/cultcache-ts/src/types.ts | 27 +- packages/cultcache-ts/test/first-call.test.ts | 47 +- .../test/store-conditional.test.ts | 175 +++++ packages/cultcache-ts/test/store-file.test.ts | 2 +- .../test/union-surface-fixture.ts | 11 + packages/cultcache-ts/test/update.test.ts | 600 ++++++++++++++++++ .../ContentVersionVectorTests.cs | 115 ++++ tests/vectors/content-version/README.md | 17 + tests/vectors/content-version/store.cc | Bin 0 -> 766 bytes .../vectors/content-version/versions.msgpack | 1 + 16 files changed, 1342 insertions(+), 106 deletions(-) create mode 100644 packages/cultcache-ts/test/store-conditional.test.ts create mode 100644 packages/cultcache-ts/test/update.test.ts create mode 100644 tests/GameCult.Caching.Tests/ContentVersionVectorTests.cs create mode 100644 tests/vectors/content-version/README.md create mode 100644 tests/vectors/content-version/store.cc create mode 100644 tests/vectors/content-version/versions.msgpack diff --git a/docs/start/README.md b/docs/start/README.md index e9dc0b3f..58874c39 100644 --- a/docs/start/README.md +++ b/docs/start/README.md @@ -17,7 +17,7 @@ this page. | | CultCache | CultMath | Geometry | CultNet | CultMesh | | --- | --- | --- | --- | --- | --- | | **C#** | [first call, tested](#c-first-call); [update, tested](#c-update-with-retry) | [package](../../packages/cultmath/README.md); snippet not yet | [package](../../src/GameCult.Geometry/README.md); snippet not yet | [first call, tested](#c-cultnet-first-call); [package](../../src/GameCult.Networking) | [quickstart](../../src/GameCult.Mesh/docs/durable-node-quickstart.md); run by `scripts/verify-eve-getting-started.ps1` | -| **TypeScript** | [first call, tested](#typescript-first-call) | not yet | not yet | [first call, tested](#typescript-cultnet-first-call); [package](../../packages/cultnet-ts/README.md) | [package](../../packages/cultmesh-ts/README.md); snippet not yet; [recipe: two-client .io game](io-game.md), tested with two live clients, browser page not yet run | +| **TypeScript** | [first call, tested](#typescript-first-call); [update, tested](#typescript-update-with-retry) | not yet | not yet | [first call, tested](#typescript-cultnet-first-call); [package](../../packages/cultnet-ts/README.md) | [package](../../packages/cultmesh-ts/README.md); snippet not yet; [recipe: two-client .io game](io-game.md), tested with two live clients, browser page not yet run | | **Rust** | [first call, tested](#rust-first-call) | [native core](../../packages/cultmath/native/cultmath-core); snippet not yet | not yet | [first call, tested](#rust-cultnet-first-call); [package](../../packages/cultnet-rs/README.md), [examples](../../packages/cultnet-rs/examples) | [package](../../packages/cultmesh-rs); snippet not yet | | **Python** | [first call, tested](#python-first-call) | not yet | not yet | [first call, tested](#python-cultnet-first-call); [package](../../packages/cultnet-py/README.md) | [package](../../packages/cultmesh-py/README.md); snippet not yet | @@ -150,6 +150,30 @@ async function firstCall(path: string) { The test imports from `../src/index` where the block reads `@gamecult/cultcache-ts`; a second test in that file fails if the block stops matching the rest. More: `packages/cultcache-ts/README.md`. +## TypeScript update with retry + +`update` changes a record from what the store holds now and commits only if it is still that record. The source is the +second region of +[`packages/cultcache-ts/test/first-call.test.ts`](../../packages/cultcache-ts/test/first-call.test.ts), which uses the +definitions of the first call above; a test in that file fails if the block stops matching the file. + +```ts +async function restock(path: string) { + const cache = CultCache.builder() + .withRegistry(registry) + .withGenericStore(new SingleFileMessagePackBackingStore(path)) + .build(); + await cache.pullAllBackingStores(); + return cache.update(itemDocument, "item:potion", (item) => { + const current = item ?? { name: "Potion", category: "Consumable", value: 0 }; + return { ...current, value: current.value + 10 }; + }); +} +``` + +The write is durable when the promise resolves. A lost race re-runs the function, so keep it free of side effects; when the +attempts run out the result says `lostRace` instead of throwing. `put` is the blind opt-out: last writer wins. + ## Rust first call Same shape with `derive(DatabaseEntry)`: slots are `#[cultcache(key = N)]`, so the `.cc` payload is the diff --git a/packages/cultcache-ts/CHANGELOG.md b/packages/cultcache-ts/CHANGELOG.md index c41071a7..b5ab90bd 100644 --- a/packages/cultcache-ts/CHANGELOG.md +++ b/packages/cultcache-ts/CHANGELOG.md @@ -5,13 +5,20 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following ## [Unreleased] +### Added + +- `CacheBackingStore` gains the optional `read(key)` (the record at the key with its content version, from one read) and `commitIf(entry, expectedVersion)` (write only if the version still holds, under the store's write lock; returns the storedAt written). The single-file store implements both. The content version is the lowercase hex SHA-256 of the record's persisted encoding `[key, schemaId, storedAt, payload]`, equal to the C# reference's on `tests/vectors/content-version`. A store without them still works for everything except `update`. +- `CultCacheUpdateResult` and `CultCacheUpdateOptions` (`maxAttempts`, default 5), exported from the package entry and the browser entry. + ### Changed +- A write that replaces a record at its key with different content (another schema id or other payload bytes) is stored strictly later than it: a supplied `storedAt` not later than the stored one is written one millisecond after it, so a content version that includes `storedAt` never repeats across writes. A rewrite of identical content (same schema id and payload bytes) keeps the stored stamp and so its version, which is what keeps a periodic flush of an unchanged cache from changing every record's version; C# re-mints every replacing write, so this is a departure from it, and the writer's type and catalog entry are written either way. `push`, `pushAll` and `commitIf` alike; the single-file store's `push` returns the stamp it wrote (`CacheBackingStore.push` may resolve `{ storedAt }`), and `put`, `putGlobal` and `putEnvelope` install that stamp in the cache, so the cache and the file agree after a put over a record stamped at or after now (a push result that is not a parseable stamp is ignored). A stamp that would pass 9999-12-31T23:59:59.999Z is refused with `RangeError` and nothing is written. - `withStoreWriteLock` looks for a link at the store path again once the lock is held, so a link put at the path while a writer waited on the lock is refused with `StoreLinkedError` and the lock released, instead of being replaced. Pinned by a test that puts the link while another holder owns the lock. - `CultCache.isWritable` does not look at store paths: a link at a store path is not predicted, and the write refuses it with `StoreLinkedError`. Pinned by a test through a cache attached to a linked store. ### Breaking +- `update` and `updateGlobal` are the conditional golden path and no longer the blind pair. `update(definition, key, apply, options?)` reads the record and its content version from the store, passes `apply` a fresh copy (never the held instance), commits only if the version still holds and runs `apply` again on a lost race, up to `maxAttempts`. `apply` may be async, may return `undefined` to write nothing, and must be free of side effects. It resolves to `{ outcome: "updated" | "declined" | "lostRace", document }` instead of the written value, so a caller reads `result.document`; a lost race that outlasts the attempts is `lostRace`, not a throw. `apply` returning `undefined` or `null` declines. `maxAttempts` is an integer of at least 1; `undefined` is the default 5 and `null` is refused with `RangeError`. A conditional write over a global loaded from a legacy key is refused wherever it enters (`updateGlobal`, or `update` at `CultCache.GLOBAL_KEY`) and leaves the legacy record untouched; `apply` may have run once before the write is refused. A store attached to a store-less cache while `apply` awaits makes that attempt a lost race. It needs a store with `read` and `commitIf` (the single-file store has them); a cache with no store compares the held entry. `put` and `putGlobal` are the blind writes and are unchanged. - A write at a single-file store path that is a symbolic link, live or dangling, is refused with the new `StoreLinkedError` (exported from the package entry; `error.path` names the path) before the lock and the replace, and writes nothing: `push`, `pushAll`, `delete` and a soft flush alike. Before, a write at a live link replaced the link with a regular file, a dangling link failed with `ENOENT` (or, for `pushAll`, was replaced), and a soft flush at a link skipped. A caller catching `ENOENT` around a write at a dangling link now catches `StoreLinkedError`; configure the link's target as the store path. Reads through a live link work. An `lstat` failure other than `ENOENT` (`ENOTDIR`, `EACCES`, `ELOOP`) is rethrown as it is. - `SingleFileMessagePackBackingStore.pushAll` writes the given entries onto the store as it is under the lock, as `push` does: a record under the same key is replaced and no other is removed. It used to replace the whole file with the entries, so `CultMesh.flush()` from a cache that held less than the store (a node created with `pullOnStart: false`, or one whose image was stale) wiped the records it never read. A caller that relied on `pushAll` to reset a store removes the file first. A soft flush is unchanged. `CultCache.storedSnapshot()` is the image as the stores hold it (a global loaded from a legacy key under that key) and is what `CultMesh.flush` persists, so a flush no longer leaves such a global under two keys; `snapshot()` is unchanged. - A single-file store marked `cultcache.store.v3` (it holds an element id, written by the C# runtime) is read, and a rewrite keeps the mark and the ids; this runtime does not decide when a document carries ids. A runtime from before this change refuses a v3 store by name, so rolling back to an older release after a store has been written as v3 is one-way: there is no carry-back, so restore from a copy taken before upgrading. @@ -22,7 +29,7 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following the many-row selection aliases (never indexed for lookup, same catalog `indexAlias`). The per-key migration rule is in the README; classify every key before the CultLib your tools load is updated. The refusal verdict is the contract, its text is informational. - The single-file reader (v1 and the legacy envelope array) refuses a store that holds two records under one key as `StoreUnreadableError`; a store that opened before may now be refused. Repair it by removing the extra record with the runtime that wrote it. -- A `put`, `push` or `pushAll` that would land a second type under a held key (against the file, or within one batch) is refused with `SchemaConflictError`, and nothing is written. The written type may replace the record when its catalog entry carries the stored schema id as its own or lists it compatible (a type renamed under a stable id restamps the record, as C# does); otherwise retyping a key is a delete, then a put. The C# allowance for an undeclared older id of the same schema name that reads whole is not ported: a same-named type keeps passing, including one that drops persisted slots, which C# refuses. +- A `put`, `push` or `pushAll` that would land a second type under a held key (against the file, or within one batch) is refused with `SchemaConflictError`, and nothing is written. The written type may replace the record when its catalog entry carries the stored schema id as its own or lists it compatible (a type renamed under a stable id restamps the record, as C# does, when its payload changes; an identical payload keeps the stamp); otherwise retyping a key is a delete, then a put. The C# allowance for an undeclared older id of the same schema name that reads whole is not ported: a same-named type keeps passing, including one that drops persisted slots, which C# refuses. - Same-type writes replace by key; in one `pushAll` batch the last entry for a key wins. - A store whose canonical-schema catalog slot is not a string is refused as `StoreUnreadableError`. - The cache holds one record per key (variants:ruling:cache-refuses-retype). A `put` or `putEnvelope` that lands a second type under a key the cache holds, or under a key a store carries foreign, is refused with `SchemaConflictError` naming the key, even with no store (a memory-only cache used to hold both types at the key). A type that owns the held record's schema id (as for the store) replaces it, unless the held record is in a store other than the written type's home: that rename is refused with `SchemaConflictError` (remove the record first). Two stores holding one key, registered or foreign, refuse the load and admit nothing. A store's `delete` removes the record whose stored schema id the removal's type owns, whatever the arrived record's `type` string. diff --git a/packages/cultcache-ts/README.md b/packages/cultcache-ts/README.md index 625d28dd..2aa78c9c 100644 --- a/packages/cultcache-ts/README.md +++ b/packages/cultcache-ts/README.md @@ -168,10 +168,8 @@ const settings = cache.getRequiredGlobal(settingsDocument); - `getByIndex(...)` - `getGlobal(...)` - `getRequiredGlobal(...)` -- `put(...)` -- `putGlobal(...)` -- `update(...)` -- `updateGlobal(...)` +- `update(...)` and `updateGlobal(...)`, the conditional golden path: apply a function to a fresh copy of the record as the store holds it, commit only if it is unchanged, re-run on a lost race (`updated`, `declined` or `lostRace`) +- `put(...)` and `putGlobal(...)`, the blind opt-out: last writer wins - `delete(...)` - `deleteGlobal(...)` - `snapshot()` (the logical image) and `storedSnapshot()` (the image as the stores hold it, for a whole flush) diff --git a/packages/cultcache-ts/src/browser.ts b/packages/cultcache-ts/src/browser.ts index 98995acb..05a5ecd6 100644 --- a/packages/cultcache-ts/src/browser.ts +++ b/packages/cultcache-ts/src/browser.ts @@ -35,6 +35,8 @@ export type { InferCultCacheSchemaValue, CultCacheStoreRegistration, CultCacheUnreadSlots, + CultCacheUpdateOptions, + CultCacheUpdateResult, PushAllOptions, } from "./types"; export type { diff --git a/packages/cultcache-ts/src/cult-cache.ts b/packages/cultcache-ts/src/cult-cache.ts index 543356aa..c8d1cc62 100644 --- a/packages/cultcache-ts/src/cult-cache.ts +++ b/packages/cultcache-ts/src/cult-cache.ts @@ -22,6 +22,8 @@ import type { CultCacheSchemaCatalogEntry, CultCacheStoreRegistration, CultCacheUnreadSlots, + CultCacheUpdateOptions, + CultCacheUpdateResult, } from "./types"; type Accessor = (value: unknown) => string | undefined; @@ -48,6 +50,10 @@ type EntryLookups = { readonly indexes: ReadonlyMap; }; +// What a conditional write requires of the record at its key: the content version its store reported (null: no record), or for a +// cache with no store the entry it held. Every write installs a new entry object, so the identity catches an A-B-A. +type WriteCondition = { readonly version: string | null } | { readonly held: HydratedEntry | undefined }; + type HydratedEntry = CultCacheEnvelope & { catalogEntry: NonNullable; value: unknown; @@ -302,85 +308,88 @@ export class CultCache { // HydratedState, running every check and every user accessor, and replaces the cache's state // in one assignment only once the build finishes. A refused load admits nothing. pullAllBackingStores(): Promise { - return this.#serial(async () => { - const next = emptyHydratedState(); - const persistedGlobalKeys = new Map(); + return this.#serial(() => this.#pullNow()); + } - for (const registration of this.#stores) { - const entries = await registration.store.pullAll(); + // The pull itself, for a caller already inside a serial operation. + async #pullNow(): Promise { + const next = emptyHydratedState(); + const persistedGlobalKeys = new Map(); - for (const entry of entries) { - const registered = this.#resolveDefinitionForEnvelope(entry); - if (!registered) { - // A store may hold what this cache has no type for; it is carried, not served, and not a reason to refuse the load. - next.foreign.push({ - key: entry.key, - schemaId: schemaIdForEnvelope(entry), - schemaName: entry.catalogEntry?.schemaName ?? entry.type, - storedAt: entry.storedAt, - }); - continue; - } + for (const registration of this.#stores) { + const entries = await registration.store.pullAll(); - const type = registered.definition.type; - const home = this.#resolveRegistration(type); - if (home?.store !== registration.store) { + for (const entry of entries) { + const registered = this.#resolveDefinitionForEnvelope(entry); + if (!registered) { + // A store may hold what this cache has no type for; it is carried, not served, and not a reason to refuse the load. + next.foreign.push({ + key: entry.key, + schemaId: schemaIdForEnvelope(entry), + schemaName: entry.catalogEntry?.schemaName ?? entry.type, + storedAt: entry.storedAt, + }); + continue; + } + + const type = registered.definition.type; + const home = this.#resolveRegistration(type); + if (home?.store !== registration.store) { + throw new Error( + `CultCache "${type}" record "${entry.key}" was loaded from ${this.#describe(registration)}, ` + + `but its home is ${this.#describe(home)}.`, + ); + } + + const payload = this.#cloneBytes(entry.payload); + const { value, unreadSlots } = registered.formatter.decode(payload); + let admitted: CultCacheEnvelope = { ...entry, type, payload }; + if (registered.global) { + const persistedKey = persistedGlobalKeys.get(type); + if (persistedKey !== undefined) { throw new Error( - `CultCache "${type}" record "${entry.key}" was loaded from ${this.#describe(registration)}, ` + - `but its home is ${this.#describe(home)}.`, + `CultCache global document type "${type}" has multiple persisted entries: "${persistedKey}" and "${entry.key}".`, ); } - - const payload = this.#cloneBytes(entry.payload); - const { value, unreadSlots } = registered.formatter.decode(payload); - let admitted: CultCacheEnvelope = { ...entry, type, payload }; - if (registered.global) { - const persistedKey = persistedGlobalKeys.get(type); - if (persistedKey !== undefined) { - throw new Error( - `CultCache global document type "${type}" has multiple persisted entries: "${persistedKey}" and "${entry.key}".`, - ); - } - persistedGlobalKeys.set(type, entry.key); - if (entry.key !== CultCache.GLOBAL_KEY) { - next.legacyGlobals.set(type, admitted); - admitted = { ...admitted, key: CultCache.GLOBAL_KEY }; - } + persistedGlobalKeys.set(type, entry.key); + if (entry.key !== CultCache.GLOBAL_KEY) { + next.legacyGlobals.set(type, admitted); + admitted = { ...admitted, key: CultCache.GLOBAL_KEY }; } - const hydrated = this.#admitEntry(next, registered, admitted, value, unreadSlots); - const other = this.#heldUnderOtherType(next, type, hydrated.key); - if (other !== undefined) { - throw new Error(`Record ${hydrated.key} is held by two stores, as type "${other.type}" and as type "${type}".`); - } - this.#installEntry(next, registered, hydrated); } - } - - const foreignByKey = new Map(); - for (const record of next.foreign) { - const first = foreignByKey.get(record.key); - if (first !== undefined) { - throw new Error(`Record ${record.key} is held by two stores, as schema "${first}" and as schema "${record.schemaName}".`); - } - foreignByKey.set(record.key, record.schemaName); - const other = [...this.#definitions.keys()].map((type) => next.entries.get(this.#entryId(type, record.key))).find((held) => held !== undefined); + const hydrated = this.#admitEntry(next, registered, admitted, value, unreadSlots); + const other = this.#heldUnderOtherType(next, type, hydrated.key); if (other !== undefined) { - throw new Error(`Record ${record.key} is held by two stores, as type "${other.type}" and as schema "${record.schemaName}".`); + throw new Error(`Record ${hydrated.key} is held by two stores, as type "${other.type}" and as type "${type}".`); + } + this.#installEntry(next, registered, hydrated); + } + } + + const foreignByKey = new Map(); + for (const record of next.foreign) { + const first = foreignByKey.get(record.key); + if (first !== undefined) { + throw new Error(`Record ${record.key} is held by two stores, as schema "${first}" and as schema "${record.schemaName}".`); + } + foreignByKey.set(record.key, record.schemaName); + const other = [...this.#definitions.keys()].map((type) => next.entries.get(this.#entryId(type, record.key))).find((held) => held !== undefined); + if (other !== undefined) { + throw new Error(`Record ${record.key} is held by two stores, as type "${other.type}" and as schema "${record.schemaName}".`); + } + } + + // Judged whole, once: the refusal names the least violation of the state this load would leave, whatever the + // order of the stores and of the entries in them. + refuseLeastIndexSharing(function* () { + for (const entry of next.entries.values()) { + for (const [alias, value] of entry.lookups.indexes) { + yield { type: entry.type, schemaName: entry.catalogEntry.schemaName, alias, value, key: entry.key }; } } + }()); - // Judged whole, once: the refusal names the least violation of the state this load would leave, whatever the - // order of the stores and of the entries in them. - refuseLeastIndexSharing(function* () { - for (const entry of next.entries.values()) { - for (const [alias, value] of entry.lookups.indexes) { - yield { type: entry.type, schemaName: entry.catalogEntry.schemaName, alias, value, key: entry.key }; - } - } - }()); - - this.#hydrated = next; - }); + this.#hydrated = next; } get( @@ -513,12 +522,16 @@ export class CultCache { return key ? this.get(definition, key) : undefined; } + /** + * The blind write: the record at the key becomes `value`, whatever it held, and the last writer wins. `update` is the + * conditional path, which changes a record from what the store holds now and re-runs on a lost race. + */ put( definition: TDefinition, key: string, value: CultCacheDocumentValue, ): Promise> { - return this.#serial(() => this.#putNow(definition, key, value)); + return this.#serial(async () => (await this.#putNow(definition, key, value)).value); } putEnvelope( @@ -560,31 +573,68 @@ export class CultCache { definition: TDefinition, value: CultCacheDocumentValue, ): Promise> { - return this.#serial(() => { + return this.#serial(async () => { this.#requireGlobal(definition); - return this.#putNow(definition, CultCache.GLOBAL_KEY, value); + return (await this.#putNow(definition, CultCache.GLOBAL_KEY, value)).value; }); } - update( + /** + * Changes the record at the key from what the store holds now. `apply` may run up to `maxAttempts` times (default 5): each + * attempt gets a fresh copy of the record as the store holds it (undefined when absent), which it may change and return, and + * the result is written only if the record is still that version; a lost race runs `apply` again on the newer record. It must + * not write this cache or have side effects. Returning undefined writes nothing (`declined`). A lost race that outlasts + * `maxAttempts` returns `lostRace` and does not throw; a refusal, or a throw from `apply`, rejects and is not retried. The + * write is durable when the promise resolves. `document` is the cache's record after the outcome. `put` is the blind write. + */ + async update( definition: TDefinition, key: string, - updater: ( + apply: ( current: CultCacheDocumentValue | undefined, - ) => CultCacheDocumentValue, - ): Promise> { - return this.#serial(() => this.#putNow(definition, key, updater(this.get(definition, key)))); + ) => CultCacheDocumentValue | undefined | Promise | undefined>, + options: CultCacheUpdateOptions = {}, + ): Promise>> { + if (typeof apply !== "function") { + throw new TypeError("CultCache update needs a function to apply."); + } + // undefined is the default; anything else must be an integer of at least 1, so null is refused. + const maxAttempts = options.maxAttempts === undefined ? 5 : options.maxAttempts; + if (!Number.isInteger(maxAttempts) || maxAttempts < 1) { + throw new RangeError(`CultCache update maxAttempts must be an integer of at least 1, not ${String(maxAttempts)}.`); + } + + for (let attempt = 1; ; attempt++) { + const { expected, copy } = await this.#serial(() => this.#readForUpdate(definition, key)); + // Outside the serial queue and the store's lock: apply may await, and may write this cache through other keys. + const next = await apply(copy as CultCacheDocumentValue | undefined); + if (next === undefined || next === null) { + return { outcome: "declined", document: this.get(definition, key) }; + } + const written = await this.#serial(() => this.#putNow(definition, key, next, expected)); + if (written.outcome === "committed") { + return { outcome: "updated", document: this.get(definition, key) as CultCacheDocumentValue }; + } + if (attempt === maxAttempts) { + // Display only. A cache with no store has nothing to pull, and a pull would replace what it holds with nothing. + if (this.#stores.length > 0) { + await this.#serial(() => this.#pullNow()); + } + return { outcome: "lostRace", document: this.get(definition, key) }; + } + } } - updateGlobal( + /** `update` of the type's global record. */ + async updateGlobal( definition: TDefinition, - updater: ( + apply: ( current: CultCacheDocumentValue | undefined, - ) => CultCacheDocumentValue, - ): Promise> { - return this.#serial(() => - this.#putNow(definition, CultCache.GLOBAL_KEY, updater(this.getGlobal(definition))), - ); + ) => CultCacheDocumentValue | undefined | Promise | undefined>, + options: CultCacheUpdateOptions = {}, + ): Promise>> { + this.#requireGlobal(definition); + return this.update(definition, CultCache.GLOBAL_KEY, apply, options); } delete( @@ -724,11 +774,55 @@ export class CultCache { this.#stores.push(candidate); } + // One attempt's read for update: the record at the key as its home holds it now, the condition a commit must meet, and a copy of + // the record for apply to change. A store-backed cache asks its store, which reports the record and its version from one read; + // a cache with no store has only the held entry, whose identity is the condition and whose admitted payload is the copy. + async #readForUpdate( + definition: AnyCultCacheDocumentDefinition, + key: string, + ): Promise<{ expected: WriteCondition; copy: unknown }> { + const registered = this.#requireDefinition(definition); + this.#requireGlobalKey(registered, key); + const type = definition.type; + this.#refuseLegacyConditional(type); + const home = this.#homeStore(type); + if (home === undefined) { + const held = this.#hydrated.entries.get(this.#entryId(type, key)); + if (held === undefined && this.#heldUnderOtherType(this.#hydrated, type, key) !== undefined) { + throw new Error(`Record ${key} is not a "${type}"; update cannot copy it.`); + } + return { expected: { held }, copy: held ? registered.formatter.decode(this.#cloneBytes(held.payload)).value : undefined }; + } + if (home.read === undefined || home.commitIf === undefined) { + throw new Error(`CultCache backing store for type "${type}" cannot write conditionally; update needs a store with read and commitIf.`); + } + const found = await home.read(key); + if (found === undefined) { + return { expected: { version: null }, copy: undefined }; + } + if (this.#resolveDefinitionForEnvelope(found.envelope) !== registered) { + throw new Error(`Record ${key} is not a "${type}"; update cannot copy it.`); + } + return { expected: { version: found.version }, copy: registered.formatter.decode(this.#cloneBytes(found.envelope.payload)).value }; + } + + #putNow( + definition: TDefinition, + key: string, + value: CultCacheDocumentValue, + ): Promise<{ outcome: "committed"; value: CultCacheDocumentValue }>; + #putNow( + definition: TDefinition, + key: string, + value: CultCacheDocumentValue, + expected: WriteCondition, + ): Promise<{ outcome: "committed"; value: CultCacheDocumentValue } | { outcome: "mismatch" }>; async #putNow( definition: TDefinition, key: string, value: CultCacheDocumentValue, - ): Promise> { + expected?: WriteCondition, + ): Promise<{ outcome: "committed"; value: CultCacheDocumentValue } | { outcome: "mismatch" }> { const registered = this.#requireDefinition(definition); const payload = registered.formatter.encode(value); const { value: parsed, unreadSlots } = registered.formatter.decode(payload) as { value: CultCacheDocumentValue; unreadSlots?: NotedSlots }; @@ -740,19 +834,55 @@ export class CultCache { schemaId: registered.catalogEntry.schemaId, catalogEntry: registered.catalogEntry, }; - await this.#writeNow(registered, entry, parsed, unreadSlots); - return parsed; + const outcome = await this.#writeNow(registered, entry, parsed, unreadSlots, expected); + return outcome === "committed" ? { outcome, value: parsed } : { outcome }; } // Validates the whole write (home, global singleton, name and index accessors) before the store // is touched; once the store accepts, applying to the cache has no fallible step. - async #writeNow(registered: RegisteredDefinition, entry: CultCacheEnvelope, value: unknown, unreadSlots?: NotedSlots): Promise { + // With a condition, the write lands only if the record at the key is still the one the caller read: the store's version + // (commitIf), or for a cache with no store the held entry itself. Otherwise nothing is written or installed and the answer is + // "mismatch". Without one the write is blind, as put's. + async #writeNow( + registered: RegisteredDefinition, + entry: CultCacheEnvelope, + value: unknown, + unreadSlots?: NotedSlots, + expected?: WriteCondition, + ): Promise<"committed" | "mismatch"> { const home = this.#homeStore(entry.type); const retyped = this.#judge(registered, entry.key, entry); const hydrated = this.#admitEntry(this.#hydrated, registered, entry, value, unreadSlots); this.#refuseIndexSharing(this.#hydrated, entry.type, registered.catalogEntry.schemaName, entry.key, hydrated.lookups); const legacy = this.#hydrated.legacyGlobals.get(entry.type); - await home?.push(entry); + // A conditional write over a legacy global is refused here too (a pull while apply awaited can have loaded one): the condition + // was read from the global's own key, the record sits under its legacy key, and the retire below would delete it unseen. + if (expected !== undefined) { + this.#refuseLegacyConditional(entry.type); + } + if (expected !== undefined && "held" in expected) { + // The condition was fixed at read for a cache with no store. A store attached since has its own record at the key that + // this read never saw, so the held identity no longer judges the write: lose the race and read again. + if (home !== undefined || this.#hydrated.entries.get(this.#entryId(entry.type, entry.key)) !== expected.held) { + return "mismatch"; + } + } + if (expected !== undefined && "version" in expected) { + if (home?.commitIf === undefined) { + throw new Error(`CultCache backing store for type "${entry.type}" cannot write conditionally; update needs a store with read and commitIf.`); + } + const committed = await home.commitIf(entry, expected.version); + if (committed.outcome === "mismatch") { + return "mismatch"; + } + hydrated.storedAt = committed.storedAt; + } else { + // A store that re-stamps a write (storedAt later than the record it replaces) reports the stamp; the cache holds that one. + const pushed = await home?.push(entry); + if (typeof pushed === "object" && pushed !== null && typeof pushed.storedAt === "string" && !Number.isNaN(Date.parse(pushed.storedAt))) { + hydrated.storedAt = pushed.storedAt; + } + } if (home && legacy) { await this.#retireLegacyGlobal(home, legacy, entry); } @@ -762,6 +892,17 @@ export class CultCache { this.#removeEntry(this.#hydrated, this.#definitions.get(retyped.type)!, retyped); } this.#installEntry(this.#hydrated, registered, hydrated); + return "committed"; + } + + // The one rule that a conditional write (update, whichever entry reaches it) never touches a global loaded from a legacy key: + // only putGlobal retires that key. Asked before apply runs and again at the write. + #refuseLegacyConditional(type: string): void { + if (this.#hydrated.legacyGlobals.has(type)) { + throw new Error( + `CultCache global "${type}" was loaded from a legacy key; write it once with putGlobal to retire its legacy key.`, + ); + } } // An envelope of the definition's type at the key, carrying nothing: what the judge asks about a write or a removal by that type. diff --git a/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts b/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts index b5199ae3..03b93683 100644 --- a/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts +++ b/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { lstatSync, readFileSync } from "node:fs"; import { mkdir } from "node:fs/promises"; import { dirname, resolve } from "node:path"; @@ -55,14 +56,42 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore { return disk.envelopes; } - async push(entry: CultCacheEnvelope): Promise { - await this.#enqueue(async () => { + async push(entry: CultCacheEnvelope): Promise<{ storedAt: string }> { + return this.#enqueue(async () => { const disk = this.#readDisk(); this.#format = disk.format; const existing = disk.envelopes; requireOneTypePerKey(this.filePath, [entry], existing); - const filtered = existing.filter((candidate) => candidate.key !== entry.key); - await this.#writeAll([entry], filtered); + const [written] = await this.#writeAll([entry], existing); + return { storedAt: written!.storedAt }; + }); + } + + // The record at the key as the file holds it now, with its content version, from one read. Like pullAll it takes no lock: a + // reader sees a whole file (the commit renames one over the other), and the version it reports is the one commitIf judges. + async read(key: string): Promise<{ envelope: CultCacheEnvelope; version: string } | undefined> { + const envelope = readSingleFileStore(this.filePath).envelopes.find((candidate) => candidate.key === key); + return envelope === undefined ? undefined : { envelope, version: recordVersion(envelope) }; + } + + // Writes `entry` the way push does, but only if the record at its key (of any type) still has `expectedVersion`; null expects + // none. The comparison and the write are one hold of the write lock, so no other writer lands between them. + async commitIf( + entry: CultCacheEnvelope, + expectedVersion: string | null, + ): Promise<{ outcome: "committed"; storedAt: string } | { outcome: "mismatch" }> { + return this.#enqueue(async () => { + const disk = this.#readDisk(); + this.#format = disk.format; + const existing = disk.envelopes; + const current = existing.find((candidate) => candidate.key === entry.key); + if ((current === undefined ? null : recordVersion(current)) !== expectedVersion) { + return { outcome: "mismatch" as const }; + } + + requireOneTypePerKey(this.filePath, [entry], existing); + const [written] = await this.#writeAll([entry], existing); + return { outcome: "committed" as const, storedAt: written!.storedAt }; }); } @@ -126,13 +155,67 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore { return result; } - async #writeAll(supplied: CultCacheEnvelope[], arrived: CultCacheEnvelope[]): Promise { + // Writes the supplied records onto the arrived ones and returns the supplied records as written. A supplied record whose + // schema id and payload bytes differ from the arrived one it replaces is never stamped earlier than the record it replaces, so a content version that includes storedAt + // cannot repeat across writes (variants:ruling:storedat-on-change): one not later than the arrived stamp is written one + // millisecond after it. + async #writeAll(supplied: CultCacheEnvelope[], arrived: CultCacheEnvelope[]): Promise { + const arrivedByKey = new Map(arrived.map((entry) => [entry.key, entry])); + const written = supplied.map((entry) => { + const prior = arrivedByKey.get(entry.key); + if (prior === undefined) { + return entry; + } + // A rewrite of the identical record (same schema id and payload bytes) keeps the stamp and so the version it has; the + // writer's type and catalog entry still go down with it. + if (sameContent(entry, prior)) { + return { ...entry, storedAt: prior.storedAt }; + } + return { ...entry, storedAt: laterStoredAt(entry.storedAt, prior.storedAt) }; + }); await mkdir(dirname(this.filePath), { recursive: true }); - await commitStoreFile(this.filePath, encode(encodeSnapshot(supplied, arrived, this.#format))); + await commitStoreFile(this.filePath, encode(encodeSnapshot(written, arrived, this.#format))); + return written; } } +// The content version of a stored record: the lowercase hex SHA-256 of its persisted encoding, the MessagePack array +// [key, schemaId, storedAt, payload] as the store writes it (the C# reference's StoredVersion). Private to this module so +// node:crypto stays out of the browser-safe cache. +export function recordVersion(entry: CultCacheEnvelope): string { + return createHash("sha256") + .update(encode([entry.key, schemaIdForEnvelope(entry), entry.storedAt, documentPayloadBytes(entry.payload)])) + .digest("hex"); +} + +function sameContent(a: CultCacheEnvelope, b: CultCacheEnvelope): boolean { + if (schemaIdForEnvelope(a) !== schemaIdForEnvelope(b)) { + return false; + } + const x = documentPayloadBytes(a.payload); + const y = documentPayloadBytes(b.payload); + return x.length === y.length && x.every((byte, i) => byte === y[i]); +} + +// `storedAt` if it is later than `prior`, else `prior` plus one millisecond. A stamp that does not parse is not later; a prior +// that does not parse leaves nothing to be later than. +function laterStoredAt(storedAt: string, prior: string): string { + const priorMs = Date.parse(prior); + if (Number.isNaN(priorMs)) { + return storedAt; + } + const storedMs = Date.parse(storedAt); + if (!(Number.isNaN(storedMs) || storedMs <= priorMs)) { + return storedAt; + } + // The stamp is ISO-8601 with a four-digit year; one past 9999-12-31T23:59:59.999Z would print as an extended year. + if (priorMs + 1 > Date.UTC(9999, 11, 31, 23, 59, 59, 999)) { + throw new RangeError(`CultCache cannot stamp a write later than the record it replaces (${prior}): the stamp would leave the four-digit years.`); + } + return new Date(priorMs + 1).toISOString(); +} + // A store holds a key once, and the reader refuses a file that holds it twice, so a write that would put a second type under a // key (against a record on disk, or within its own batch) is refused before anything is written. Retyping a record is a delete // of the old type followed by a put of the new one. diff --git a/packages/cultcache-ts/src/types.ts b/packages/cultcache-ts/src/types.ts index 10371053..60e0b878 100644 --- a/packages/cultcache-ts/src/types.ts +++ b/packages/cultcache-ts/src/types.ts @@ -125,9 +125,34 @@ export interface PushAllOptions { export interface CacheBackingStore { pullAll(): Promise; - push(entry: CultCacheEnvelope): Promise; + /** A store that re-stamps the write returns the storedAt it wrote; the cache then holds that stamp, not its own. */ + push(entry: CultCacheEnvelope): Promise; delete(entry: CultCacheEnvelope): Promise; pushAll?(entries: CultCacheEnvelope[], options?: PushAllOptions): Promise; + /** + * The record at the key as the store holds it now, with its content version, from one read; undefined when the store holds + * none. The version is the store's own: the cache carries it from `read` to `commitIf` and never computes it. + */ + read?(key: string): Promise<{ envelope: CultCacheEnvelope; version: string } | undefined>; + /** + * Writes the entry only if the record at its key still has `expectedVersion` (null: no record), judged and written under the + * store's write lock. A mismatch writes nothing. A commit returns the storedAt actually written. + */ + commitIf?( + entry: CultCacheEnvelope, + expectedVersion: string | null, + ): Promise<{ outcome: "committed"; storedAt: string } | { outcome: "mismatch" }>; +} + +/** The outcome of `CultCache.update`: what `apply` wrote, declined to write, or lost to a rival writer. */ +export type CultCacheUpdateResult = + | { outcome: "updated"; document: TValue } + | { outcome: "declined"; document: TValue | undefined } + | { outcome: "lostRace"; document: TValue | undefined }; + +export interface CultCacheUpdateOptions { + /** How many times `apply` may run before a lost race is returned as `lostRace`. An integer of at least 1; default 5. */ + maxAttempts?: number; } export interface CultCacheStoreRegistration { diff --git a/packages/cultcache-ts/test/first-call.test.ts b/packages/cultcache-ts/test/first-call.test.ts index 45f6edd2..9ef3d308 100644 --- a/packages/cultcache-ts/test/first-call.test.ts +++ b/packages/cultcache-ts/test/first-call.test.ts @@ -46,6 +46,21 @@ async function firstCall(path: string) { } // snippet:end +// The update snippet, which docs/start/README.md shows after the first call and which uses its definitions. +// snippet:update:begin +async function restock(path: string) { + const cache = CultCache.builder() + .withRegistry(registry) + .withGenericStore(new SingleFileMessagePackBackingStore(path)) + .build(); + await cache.pullAllBackingStores(); + return cache.update(itemDocument, "item:potion", (item) => { + const current = item ?? { name: "Potion", category: "Consumable", value: 0 }; + return { ...current, value: current.value + 10 }; + }); +} +// snippet:update:end + test("define, store and read back a typed document", async () => { const dir = await mkdtemp(join(tmpdir(), "cultcache-first-call-")); try { @@ -57,13 +72,35 @@ test("define, store and read back a typed document", async () => { } }); -test("docs show the tested snippet", async () => { +test("update with retry: restock twice reads value 20 through a second cache", async () => { + const dir = await mkdtemp(join(tmpdir(), "cultcache-first-call-")); + try { + const path = join(dir, "restock.cc"); + assert.equal((await restock(path)).outcome, "updated"); + assert.equal((await restock(path)).outcome, "updated"); + const reopened = CultCache.builder() + .withRegistry(registry) + .withGenericStore(new SingleFileMessagePackBackingStore(path)) + .build(); + await reopened.pullAllBackingStores(); + assert.equal(reopened.get(itemDocument, "item:potion")?.value, 20); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +// The docs show the region between `// snippet:begin` and `end` markers verbatim, the package import aside. +async function assertDocsShow(region: "" | "update:") { // Compiled to dist-test/test/first-call.test.js, so the package root is two levels up. const source = await readFile(join(__dirname, "..", "..", "test", "first-call.test.ts"), "utf8"); const snippet = source - .split("// snippet:begin\n")[1] - .split("// snippet:end\n")[0] + .split(`// snippet:${region}begin\n`)[1] + .split(`// snippet:${region}end\n`)[0] .replace('"../src/index"', '"@gamecult/cultcache-ts"'); const docs = (await readFile(join(__dirname, "..", "..", "..", "..", "docs", "start", "README.md"), "utf8")).replace(/\r\n/g, "\n"); - assert.ok(docs.includes("```ts\n" + snippet + "```"), "docs/start/README.md no longer shows test/first-call.test.ts"); -}); + assert.ok(docs.includes("```ts\n" + snippet + "```"), `docs/start/README.md no longer shows the ${region || "first-call "}region of test/first-call.test.ts`); +} + +test("docs show the tested snippet", () => assertDocsShow("")); + +test("docs show the tested update snippet", () => assertDocsShow("update:")); diff --git a/packages/cultcache-ts/test/store-conditional.test.ts b/packages/cultcache-ts/test/store-conditional.test.ts new file mode 100644 index 00000000..5f8f4ed5 --- /dev/null +++ b/packages/cultcache-ts/test/store-conditional.test.ts @@ -0,0 +1,175 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { copyFile, mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import test from "node:test"; + +import { decode, encode } from "@msgpack/msgpack"; + +import { documentPayloadBytes, documentPayloadFromBytes } from "../src/document"; +import { SingleFileMessagePackBackingStore, readSingleFileStore, recordVersion } from "../src/single-file-messagepack-backing-store"; +import { schemaIdForEnvelope } from "../src/store-format"; +import type { CultCacheEnvelope } from "../src/types"; + +// The store owns the content version and the conditional verdict (variants:ruling:write-set-content-version-declared-ownership): +// read reports a record with its version, commitIf writes only while the version holds, and a write that replaces a record +// is stamped later than it (variants:ruling:storedat-on-change). + +function findVectors(from: string): string { + for (let dir = from; ; dir = resolve(dir, "..")) { + if (existsSync(join(dir, "tests", "vectors", "content-version"))) return join(dir, "tests", "vectors", "content-version"); + if (resolve(dir, "..") === dir) throw new Error("tests/vectors/content-version not found above the test."); + } +} + +const entry = (key: string, byte: number, storedAt = "2026-01-01T00:00:00.000Z", type = "t"): CultCacheEnvelope => ({ + key, + type, + payload: documentPayloadFromBytes(Uint8Array.of(0x91, byte)), + storedAt, +}); + +async function scratch(): Promise { + return mkdtemp(join(tmpdir(), "cultcache-conditional-")); +} + +test("the content version of every record of the C#-written store equals the version the reference read", async () => { + const vectors = findVectors(__dirname); + const rows = decode(readFileSync(join(vectors, "versions.msgpack"))) as Array<[string, string]>; + assert.equal(rows.length, 2); + const directory = await scratch(); + try { + const path = join(directory, "store.cc"); + await copyFile(join(vectors, "store.cc"), path); + const store = new SingleFileMessagePackBackingStore(path); + for (const [key, version] of rows) { + const found = await store.read(key); + assert.equal(found?.version, version, `read(${key}).version`); + assert.equal(recordVersion(found!.envelope), version, `recordVersion(${key})`); + } + assert.equal(await store.read("absent"), undefined); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("the version is the SHA-256 of the record's persisted encoding and changes with every field of it", async () => { + const base = entry("k", 1); + const hex = (e: CultCacheEnvelope) => + createHash("sha256").update(encode([e.key, schemaIdForEnvelope(e), e.storedAt, documentPayloadBytes(e.payload)])).digest("hex"); + assert.equal(recordVersion(base), hex(base)); + const versions = new Set([base, entry("k", 2), entry("k", 1, "2026-01-01T00:00:00.001Z"), entry("j", 1), { ...base, schemaId: "sha256:other" }].map(recordVersion)); + assert.equal(versions.size, 5); +}); + +test("commitIf writes nothing when the version does not hold, and commits when it does", async () => { + const directory = await scratch(); + try { + const path = join(directory, "store.cc"); + const store = new SingleFileMessagePackBackingStore(path); + // null expects no record: it commits over an absent key and mismatches over a present one. + assert.deepEqual(await store.commitIf(entry("k", 1), "not-a-version"), { outcome: "mismatch" }); + assert.equal(existsSync(path), false, "a mismatch creates no file"); + assert.equal((await store.commitIf(entry("k", 1), null)).outcome, "committed"); + const first = (await store.read("k"))!; + const before = await readFile(path); + assert.deepEqual(await store.commitIf(entry("k", 2), null), { outcome: "mismatch" }); + assert.deepEqual(await store.commitIf(entry("k", 2), "0".repeat(64)), { outcome: "mismatch" }); + assert.deepEqual([...(await readFile(path))], [...before], "a mismatch leaves the bytes identical"); + assert.equal((await store.commitIf(entry("k", 2), first.version)).outcome, "committed"); + const second = (await store.read("k"))!; + assert.notEqual(second.version, first.version); + assert.deepEqual([...documentPayloadBytes(second.envelope.payload)], [0x91, 2]); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("commitIf judges the record at the key whatever its type, and a record of another key does not matter", async () => { + const directory = await scratch(); + try { + const store = new SingleFileMessagePackBackingStore(join(directory, "store.cc")); + await store.push(entry("a", 1, undefined, "u")); + await store.push(entry("b", 1)); + const other = (await store.read("a"))!; + assert.deepEqual(await store.commitIf(entry("b", 2), other.version), { outcome: "mismatch" }); + assert.equal((await store.commitIf(entry("b", 2), recordVersion((await store.read("b"))!.envelope))).outcome, "committed"); + assert.deepEqual([...documentPayloadBytes((await store.read("a"))!.envelope.payload)], [0x91, 1], "the record at another key is untouched"); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("a write that replaces a record is stored strictly later than it, whatever the write's own storedAt", async () => { + const directory = await scratch(); + try { + const store = new SingleFileMessagePackBackingStore(join(directory, "store.cc")); + const at = "2026-03-04T05:06:07.100Z"; + await store.push(entry("k", 1, at)); + // Not later (equal, earlier, unparseable) is written one millisecond after the record it replaces, by push, pushAll and commitIf. + await store.push(entry("k", 2, at)); + assert.equal((await store.read("k"))!.envelope.storedAt, "2026-03-04T05:06:07.101Z"); + await store.push(entry("k", 3, "2020-01-01T00:00:00.000Z")); + assert.equal((await store.read("k"))!.envelope.storedAt, "2026-03-04T05:06:07.102Z"); + await store.pushAll([entry("k", 4, "not a time")]); + assert.equal((await store.read("k"))!.envelope.storedAt, "2026-03-04T05:06:07.103Z"); + const committed = await store.commitIf(entry("k", 5, "2020-01-01T00:00:00.000Z"), (await store.read("k"))!.version); + assert.deepEqual(committed, { outcome: "committed", storedAt: "2026-03-04T05:06:07.104Z" }); + assert.equal((await store.read("k"))!.envelope.storedAt, "2026-03-04T05:06:07.104Z"); + // A later stamp is kept as it is, and a key with no record before it is stamped as it is. + await store.push(entry("k", 6, "2030-01-01T00:00:00.000Z")); + await store.push(entry("fresh", 1, "2000-01-01T00:00:00.000Z")); + assert.equal((await store.read("k"))!.envelope.storedAt, "2030-01-01T00:00:00.000Z"); + assert.equal((await store.read("fresh"))!.envelope.storedAt, "2000-01-01T00:00:00.000Z"); + assert.equal(readSingleFileStore(join(directory, "store.cc")).envelopes.length, 2); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("a replacing write that would stamp past year 9999 is refused and writes nothing", async () => { + const directory = await scratch(); + try { + const store = new SingleFileMessagePackBackingStore(join(directory, "store.cc")); + const last = "9999-12-31T23:59:59.999Z"; + await store.push(entry("k", 1, last)); + await assert.rejects(() => store.push(entry("k", 2, last)), RangeError); + assert.equal((await store.read("k"))!.envelope.storedAt, last); + assert.deepEqual([...documentPayloadBytes((await store.read("k"))!.envelope.payload)], [0x91, 1]); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("a write that restores an earlier value does not restore its version", async () => { + const directory = await scratch(); + try { + const store = new SingleFileMessagePackBackingStore(join(directory, "store.cc")); + const at = "2026-03-04T05:06:07.100Z"; + await store.push(entry("k", 1, at)); + const first = (await store.read("k"))!.version; + await store.push(entry("k", 5, at)); + await store.push(entry("k", 1, at)); + assert.notEqual((await store.read("k"))!.version, first, "an A-B-A within one millisecond keeps no version"); + assert.deepEqual(await store.commitIf(entry("k", 9, at), first), { outcome: "mismatch" }); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("two stores on one file never both commit against the same version", async () => { + const directory = await scratch(); + try { + const path = join(directory, "store.cc"); + const left = new SingleFileMessagePackBackingStore(path); + const right = new SingleFileMessagePackBackingStore(path); + await left.push(entry("k", 0)); + const version = (await left.read("k"))!.version; + const outcomes = await Promise.all(Array.from({ length: 6 }, (_, i) => (i % 2 === 0 ? left : right).commitIf(entry("k", i + 1), version))); + assert.equal(outcomes.filter((o) => o.outcome === "committed").length, 1); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/packages/cultcache-ts/test/store-file.test.ts b/packages/cultcache-ts/test/store-file.test.ts index 7ddf7fe3..6cb70392 100644 --- a/packages/cultcache-ts/test/store-file.test.ts +++ b/packages/cultcache-ts/test/store-file.test.ts @@ -424,7 +424,7 @@ test("a link put while a writer waits on the store lock is refused once it holds await new SingleFileMessagePackBackingStore(target).push(noteEnvelope("a")); const before = await readFile(target); const writes: Array<[string, (store: SingleFileMessagePackBackingStore) => Promise]> = [ - ["push", (store) => store.push(noteEnvelope("b"))], + ["push", async (store) => void (await store.push(noteEnvelope("b")))], ["pushAll", (store) => store.pushAll([noteEnvelope("b")])], ["delete", (store) => store.delete(noteEnvelope("a"))], ]; diff --git a/packages/cultcache-ts/test/union-surface-fixture.ts b/packages/cultcache-ts/test/union-surface-fixture.ts index 1b556eb1..28089522 100644 --- a/packages/cultcache-ts/test/union-surface-fixture.ts +++ b/packages/cultcache-ts/test/union-surface-fixture.ts @@ -7,6 +7,7 @@ import { z } from "zod"; import { codecs } from "../src"; import type { Codec } from "../src/codec"; import { defineDocumentType, documentPayloadFromBytes } from "../src/document"; +import { recordVersion } from "../src/single-file-messagepack-backing-store"; import type { CacheBackingStore, CultCacheEnvelope } from "../src/types"; // The union-surface fixture: the codecs, schema and value tests/vectors/union-parity was written from, shared by the @@ -235,6 +236,16 @@ export function memoryStore(entries: CultCacheEnvelope[]): CacheBackingStore & { async pullAll() { return entries.map((entry) => ({ ...entry })); }, async push(entry) { entries.push(entry); }, async delete() {}, + async read(key) { + const envelope = [...entries].reverse().find((entry) => entry.key === key); + return envelope === undefined ? undefined : { envelope, version: recordVersion(envelope) }; + }, + async commitIf(entry, expectedVersion) { + const current = [...entries].reverse().find((candidate) => candidate.key === entry.key); + if ((current === undefined ? null : recordVersion(current)) !== expectedVersion) return { outcome: "mismatch" }; + entries.push(entry); + return { outcome: "committed", storedAt: entry.storedAt }; + }, }; } diff --git a/packages/cultcache-ts/test/update.test.ts b/packages/cultcache-ts/test/update.test.ts new file mode 100644 index 00000000..fb0d56fd --- /dev/null +++ b/packages/cultcache-ts/test/update.test.ts @@ -0,0 +1,600 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { setImmediate as tick } from "node:timers/promises"; + +import { decode, encode } from "@msgpack/msgpack"; +import { z } from "zod"; + +import { CultCache } from "../src/cult-cache"; +import { defineDocumentRegistry, defineDocumentType, documentPayloadBytes, documentPayloadFromBytes } from "../src/document"; +import { SingleFileMessagePackBackingStore, readSingleFileStore } from "../src/single-file-messagepack-backing-store"; +import { SchemaConflictError, schemaIdForEnvelope } from "../src/store-format"; +import type { CacheBackingStore } from "../src/types"; +import { envelopeOf, memoryStore, read, surfaceDocument } from "./union-surface-fixture"; + +// CultCache.update: read the record and its content version from the store, apply a function to a copy, commit only if the +// version still holds, run again on a lost race (variants:cut_spec:cut-update-with-retry-ts.r1; the C# tests in +// tests/GameCult.Caching.Tests/UpdateTests.cs are the parity). + +const counterDocument = defineDocumentType({ + type: "update.counter", + schemaId: "update.counter.v1", + schemaName: "update.counter", + schemaVersion: "update.counter.v1", + schema: z.object({ value: z.number().int() }), +}); +const tallyDocument = defineDocumentType({ + type: "update.tally", + schemaId: "update.tally.v1", + schemaName: "update.tally", + schemaVersion: "update.tally.v1", + schema: z.object({ count: z.number().int() }), +}); +const registry = defineDocumentRegistry(counterDocument, tallyDocument); +const settingsFields = { schemaName: "update.settings", schemaVersion: "update.settings.v1", schemaId: "update.settings.v1", schema: z.object({ theme: z.string() }) }; +const settingsDocument = defineDocumentType({ type: "update.settings", ...settingsFields, global: true }); +const plainSettingsDocument = defineDocumentType({ type: "update.settings", ...settingsFields }); + +const KEY = "counter"; + +async function withDirectory(body: (path: string) => Promise): Promise { + const directory = await mkdtemp(join(tmpdir(), "cultcache-update-")); + try { + return await body(join(directory, "store.cc")); + } finally { + await rm(directory, { recursive: true, force: true }); + } +} + +type Registry = Parameters["withRegistry"]>[0]; + +async function open(path: string, documents: Registry = registry): Promise { + const cache = CultCache.builder().withRegistry(documents).withGenericStore(new SingleFileMessagePackBackingStore(path)).build(); + await cache.pullAllBackingStores(); + return cache; +} + +// What the file holds at the key, read without a cache. +function stored(path: string, key = KEY): number | undefined { + const envelope = readSingleFileStore(path).envelopes.find((candidate) => candidate.key === key); + return envelope === undefined ? undefined : (decode(documentPayloadBytes(envelope.payload)) as { value: number }).value; +} + +const plus = (n: number) => (current: { value: number } | undefined) => ({ value: (current?.value ?? 0) + n }); + +test("two writers updating lose no increment", async () => { + await withDirectory(async (path) => { + const [left, right] = [await open(path), await open(path)]; + let calls = 0; + const loop = async (cache: CultCache) => { + const outcomes: string[] = []; + for (let i = 0; i < 50; i++) { + const result = await cache.update(counterDocument, KEY, async (current) => { + calls++; + await tick(); + return { value: (current?.value ?? 0) + 1 }; + }, { maxAttempts: 50 }); + outcomes.push(result.outcome); + } + return outcomes; + }; + const outcomes = (await Promise.all([loop(left), loop(right)])).flat(); + assert.ok(outcomes.every((outcome) => outcome === "updated"), outcomes.join()); + assert.equal(stored(path), 100); + assert.ok(calls > 100, `the caches raced: apply ran ${calls} times`); + }); +}); + +test("a lost attempt reruns apply on the fresh value", async () => { + await withDirectory(async (path) => { + const [cache, rival] = [await open(path), await open(path)]; + await cache.put(counterDocument, KEY, { value: 1 }); + const seen: Array = []; + const result = await cache.update(counterDocument, KEY, async (current) => { + seen.push(current?.value); + if (seen.length === 1) await rival.put(counterDocument, KEY, { value: 100 }); + return { value: current!.value + 1 }; + }); + assert.deepEqual(seen, [1, 100]); + assert.equal(result.outcome, "updated"); + assert.equal(result.document?.value, 101); + assert.equal(stored(path), 101); + }); +}); + +test("exhausted attempts return lostRace, and the cache then shows the store", async () => { + await withDirectory(async (path) => { + const [cache, rival] = [await open(path), await open(path)]; + await cache.put(counterDocument, KEY, { value: 1 }); + let calls = 0; + const result = await cache.update( + counterDocument, + KEY, + async (current) => { + calls++; + await rival.put(counterDocument, KEY, { value: 100 * calls }); + return { value: current!.value + 1 }; + }, + { maxAttempts: 3 }, + ); + assert.equal(calls, 3); + assert.equal(result.outcome, "lostRace"); + assert.equal(result.document?.value, 300); + assert.equal(stored(path), 300); + }); +}); + +test("five attempts by default", async () => { + await withDirectory(async (path) => { + const [cache, rival] = [await open(path), await open(path)]; + let calls = 0; + const result = await cache.update(counterDocument, KEY, async () => { + calls++; + await rival.put(counterDocument, KEY, { value: calls }); + return { value: -1 }; + }); + assert.equal(result.outcome, "lostRace"); + assert.equal(calls, 5); + }); +}); + +test("maxAttempts 1 makes one call", async () => { + await withDirectory(async (path) => { + const [cache, rival] = [await open(path), await open(path)]; + await cache.put(counterDocument, KEY, { value: 1 }); + let calls = 0; + const apply = async (current: { value: number } | undefined) => { + calls++; + await rival.put(counterDocument, KEY, { value: 50 + calls }); + return { value: current!.value + 1 }; + }; + assert.equal((await cache.update(counterDocument, KEY, apply, { maxAttempts: 1 })).outcome, "lostRace"); + assert.equal(calls, 1); + }); +}); + +// An apply that declines resolves at once whatever the bound, so a validation that lets a bad value through fails the assertion +// below instead of looping: no apply here ever loses a race. +test("invalid maxAttempts (undefined is the default, null is refused) and a non-function reject before any call", async () => { + await withDirectory(async (path) => { + const cache = await open(path); + let calls = 0; + const apply = () => (calls++, undefined); + for (const maxAttempts of [0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, null as never]) { + await assert.rejects(() => cache.update(counterDocument, KEY, apply, { maxAttempts }), RangeError, `maxAttempts ${String(maxAttempts)}`); + } + await assert.rejects(() => cache.update(counterDocument, KEY, "no" as never), TypeError); + assert.equal(calls, 0); + assert.equal((await cache.update(counterDocument, KEY, apply, { maxAttempts: undefined })).outcome, "declined"); + assert.equal(calls, 1); + }); +}); + +test("null from apply declines as undefined does: nothing is written", async () => { + await withDirectory(async (path) => { + const cache = await open(path); + await cache.put(counterDocument, KEY, { value: 1 }); + const before = await readFile(path); + const result = await cache.update(counterDocument, KEY, () => null as never); + assert.deepEqual(result, { outcome: "declined", document: cache.get(counterDocument, KEY) }); + assert.deepEqual([...(await readFile(path))], [...before]); + }); +}); + +test("a cache with no store refuses a key held as another type before apply runs", async () => { + const cache = CultCache.builder().withRegistry(registry).build(); + await cache.put(tallyDocument, KEY, { count: 1 }); + let calls = 0; + await assert.rejects(() => cache.update(counterDocument, KEY, () => (calls++, { value: 1 })), /is not a "update.counter"/); + assert.equal(calls, 0); +}); + +test("a store attached while apply awaits turns the commit into a lost race, never a blind push", async () => { + await withDirectory(async (path) => { + const writer = await open(path); + await writer.put(counterDocument, KEY, { value: 50 }); + const cache = CultCache.builder().withRegistry(registry).build(); + const seen: Array = []; + const result = await cache.update(counterDocument, KEY, async (current) => { + seen.push(current?.value); + if (seen.length === 1) cache.addBackingStore(new SingleFileMessagePackBackingStore(path)); + return { value: (current?.value ?? 0) + 1 }; + }); + assert.deepEqual(seen, [undefined, 50], "the second attempt reads the store's record"); + assert.equal(result.outcome, "updated"); + assert.equal(stored(path), 51); + }); +}); + +test("put installs the storedAt the store stamped, so the cache and the file agree", async () => { + await withDirectory(async (path) => { + const cache = await open(path); + await cache.put(counterDocument, KEY, { value: 1 }); + const store = new SingleFileMessagePackBackingStore(path); + const [envelope] = readSingleFileStore(path).envelopes; + await store.push({ ...envelope!, payload: documentPayloadFromBytes(encode({ value: 99 })), storedAt: "2099-01-01T00:00:00.000Z" }); + await cache.put(counterDocument, KEY, { value: 2 }); + const onDisk = readSingleFileStore(path).envelopes[0]!.storedAt; + assert.equal(onDisk, "2099-01-01T00:00:00.001Z"); + assert.equal(cache.getEnvelope(counterDocument, KEY)!.storedAt, onDisk); + }); +}); + +test("apply gets a fresh copy: changing it changes nothing held, and a lost attempt's change is not seen by the next", async () => { + await withDirectory(async (path) => { + const [cache, rival] = [await open(path), await open(path)]; + await cache.put(counterDocument, KEY, { value: 1 }); + const argument: Array<{ value: number } | undefined> = []; + const seen: number[] = []; + await cache.update(counterDocument, KEY, async (current) => { + argument.push(current); + assert.notEqual(current, cache.get(counterDocument, KEY)); + seen.push(current!.value); + current!.value += 1000; + assert.equal(cache.get(counterDocument, KEY)!.value, 1); + if (seen.length === 1) await rival.put(counterDocument, KEY, { value: 7 }); + return { value: current!.value + 1 }; + }); + assert.deepEqual(seen, [1, 7], "the second attempt does not see the first one's change"); + assert.notEqual(argument[0], argument[1]); + }); +}); + +test("an in-place change to the held value is neither applied nor persisted", async () => { + await withDirectory(async (path) => { + const [cache, fresh] = [await open(path), await open(path)]; + await cache.put(counterDocument, KEY, { value: 1 }); + cache.get(counterDocument, KEY)!.value = 999; + const seen: number[] = []; + const result = await cache.update(counterDocument, KEY, (current) => { + seen.push(current!.value); + return { value: current!.value + 1 }; + }); + assert.deepEqual(seen, [1]); + assert.equal(stored(path), 2); + await fresh.pullAllBackingStores(); + assert.equal(fresh.get(counterDocument, KEY)!.value, 2); + result.document!.value = 999; + await cache.update(counterDocument, KEY, plus(1)); + assert.equal(stored(path), 3); + }); +}); + +test("undefined from apply declines: nothing is written", async () => { + await withDirectory(async (path) => { + const cache = await open(path); + await cache.put(counterDocument, KEY, { value: 1 }); + const before = await readFile(path); + const held = await cache.update(counterDocument, KEY, () => undefined); + assert.equal(held.outcome, "declined"); + assert.equal(held.document, cache.get(counterDocument, KEY)); + const absent = await cache.update(counterDocument, "absent", () => undefined); + assert.deepEqual(absent, { outcome: "declined", document: undefined }); + assert.deepEqual([...(await readFile(path))], [...before]); + }); +}); + +test("an absent key is created once under a race", async () => { + await withDirectory(async (path) => { + const [left, right] = [await open(path), await open(path)]; + const outcomes = await Promise.all( + [left, right].map((cache) => + cache.update(counterDocument, KEY, async (current) => { + await tick(); + return { value: (current?.value ?? 0) + 1 }; + }), + ), + ); + assert.deepEqual(outcomes.map((result) => result.outcome), ["updated", "updated"]); + assert.equal(stored(path), 2); + }); +}); + +test("a rival writing and restoring the value still reruns apply", async () => { + await withDirectory(async (path) => { + const [cache, rival] = [await open(path), await open(path)]; + await cache.put(counterDocument, KEY, { value: 1 }); + const seen: number[] = []; + await cache.update(counterDocument, KEY, async (current) => { + seen.push(current!.value); + if (seen.length === 1) { + await rival.put(counterDocument, KEY, { value: 5 }); + await rival.put(counterDocument, KEY, { value: 1 }); + } + return { value: current!.value + 1 }; + }); + assert.deepEqual(seen, [1, 1]); + assert.equal(stored(path), 2); + }); +}); + +test("a rival removal is seen as undefined", async () => { + await withDirectory(async (path) => { + const [cache, rival] = [await open(path), await open(path)]; + await cache.put(counterDocument, KEY, { value: 1 }); + const seen: Array = []; + await cache.update(counterDocument, KEY, async (current) => { + seen.push(current?.value); + if (seen.length === 1) { + await rival.pullAllBackingStores(); + await rival.delete(counterDocument, KEY); + } + return { value: (current?.value ?? 0) + 1 }; + }); + assert.deepEqual(seen, [1, undefined]); + assert.equal(stored(path), 1); + }); +}); + +test("a stale cache's first attempt reads the store", async () => { + await withDirectory(async (path) => { + const [stale, rival] = [await open(path), await open(path)]; + await rival.put(counterDocument, KEY, { value: 1 }); + await stale.pullAllBackingStores(); + await rival.put(counterDocument, KEY, { value: 7 }); + const seen: number[] = []; + await stale.update(counterDocument, KEY, (current) => { + seen.push(current!.value); + return { value: current!.value + 1 }; + }); + assert.deepEqual(seen, [7]); + assert.equal(stored(path), 8); + }); +}); + +test("refusals are not retried and apply is not called for a record it cannot copy", async () => { + await withDirectory(async (path) => { + const cache = await open(path); + await cache.put(tallyDocument, "tally", { count: 1 }); + const before = await readFile(path); + let calls = 0; + const apply = (current: { value: number } | undefined) => { + calls++; + return { value: (current?.value ?? 0) + 1 }; + }; + // Another type at the key. + await assert.rejects(() => cache.update(counterDocument, "tally", apply), /not a "update.counter"/); + // A foreign record: a type this cache has no definition for. + const narrow = await open(path, defineDocumentRegistry(counterDocument)); + await assert.rejects(() => narrow.update(counterDocument, "tally", apply), /not a "update.counter"/); + assert.equal(calls, 0); + assert.deepEqual([...(await readFile(path))], [...before]); + + // A store without read and commitIf cannot take a conditional write. + const bare: CacheBackingStore = { pullAll: async () => [], push: async () => {}, delete: async () => {} }; + const plain = CultCache.builder().withRegistry(registry).withGenericStore(bare).build(); + await plain.pullAllBackingStores(); + await assert.rejects(() => plain.update(counterDocument, KEY, apply), /read and commitIf/); + assert.equal(calls, 0); + + // A record held read-only is refused by the judge after apply ran once; apply that throws is not retried either. + const surface = surfaceDocument(); + const entries = [envelopeOf(await read("extra-slot-record.msgpack"))]; + const readOnly = CultCache.builder().withDocumentType(surface).withGenericStore(memoryStore(entries)).build(); + await readOnly.pullAllBackingStores(); + let surfaceCalls = 0; + await assert.rejects(() => readOnly.update(surface, "surface", (value) => (surfaceCalls++, value)), SchemaConflictError); + assert.equal(surfaceCalls, 1); + assert.equal(entries.length, 1, "nothing reached the store"); + await assert.rejects( + () => cache.update(counterDocument, KEY, () => { calls++; throw new Error("boom"); }), + /boom/, + ); + assert.equal(calls, 1); + }); +}); + +test("own writes cause no retry, and updated returns the held instance", async () => { + await withDirectory(async (path) => { + const cache = await open(path); + await cache.put(counterDocument, KEY, { value: 1 }); + let calls = 0; + const apply = (current: { value: number } | undefined) => { + calls++; + return { value: current!.value + 1 }; + }; + await cache.update(counterDocument, KEY, apply); + const second = await cache.update(counterDocument, KEY, apply); + assert.equal(calls, 2); + assert.equal(second.outcome, "updated"); + assert.equal(second.document, cache.get(counterDocument, KEY)); + assert.equal(stored(path), 3); + // The cache holds the record as the store wrote it, the stamp the store chose included: over a record stamped in the future + // the store writes one millisecond after it, whatever the clock says. + const future = readSingleFileStore(path).envelopes[0]!; + await new SingleFileMessagePackBackingStore(path).push({ ...future, payload: documentPayloadFromBytes(encode({ value: 99 })), storedAt: "2099-01-01T00:00:00.000Z" }); + await cache.update(counterDocument, KEY, apply); + assert.equal(readSingleFileStore(path).envelopes[0]!.storedAt, "2099-01-01T00:00:00.001Z"); + assert.equal(cache.getEnvelope(counterDocument, KEY)!.storedAt, "2099-01-01T00:00:00.001Z"); + }); +}); + +test("apply runs outside the cache's serial queue", async () => { + await withDirectory(async (path) => { + const cache = await open(path); + let calls = 0; + const result = await cache.update(counterDocument, KEY, async (current) => { + calls++; + await Promise.race([ + cache.put(tallyDocument, "other", { count: 1 }), + new Promise((_, reject) => setTimeout(() => reject(new Error("a cache call from apply did not finish")), 5000).unref()), + ]); + return { value: (current?.value ?? 0) + 1 }; + }); + assert.equal(result.outcome, "updated"); + assert.equal(calls, 1); + }); +}); + +test("a cache with no store loses no increment under interleaved updates", async () => { + const cache = CultCache.builder().withRegistry(registry).build(); + await cache.pullAllBackingStores(); + let calls = 0; + const loop = async () => { + let updated = 0; + for (let i = 0; i < 50; i++) { + const result = await cache.update(counterDocument, KEY, async (current) => { + calls++; + await tick(); + return { value: (current?.value ?? 0) + 1 }; + }); + if (result.outcome === "updated") updated++; + } + return updated; + }; + // The two loops run in lockstep, so one may lose every round (a lostRace is the answer, not a lost write): what is held is + // exactly what the updated outcomes wrote. + const updated = (await Promise.all([loop(), loop()])).reduce((sum, n) => sum + n, 0); + assert.equal(cache.get(counterDocument, KEY)!.value, updated); + assert.ok(calls > updated, `the loops raced: apply ran ${calls} times for ${updated} writes`); +}); + +test("a store-less cache copies the bytes it admitted, not the held value", async () => { + const cache = CultCache.builder().withRegistry(registry).build(); + await cache.put(counterDocument, KEY, { value: 1 }); + cache.get(counterDocument, KEY)!.value = 999; + const seen: number[] = []; + await cache.update(counterDocument, KEY, (current) => (seen.push(current!.value), { value: current!.value + 1 })); + assert.deepEqual(seen, [1]); +}); + +test("updateGlobal updates the global record and refuses one loaded from a legacy key until putGlobal retires it", async () => { + await withDirectory(async (path) => { + const settings = defineDocumentRegistry(settingsDocument); + const cache = await open(path, settings); + const first = await cache.updateGlobal(settingsDocument, (current) => ({ theme: `${current?.theme ?? ""}a` })); + assert.equal(first.outcome, "updated"); + assert.deepEqual(cache.getGlobal(settingsDocument), { theme: "a" }); + assert.equal(readSingleFileStore(path).envelopes[0]!.key, CultCache.GLOBAL_KEY); + await rm(path); + + const legacy = await open(path, defineDocumentRegistry(plainSettingsDocument)); + await legacy.put(plainSettingsDocument, "old-settings", { theme: "x" }); + const loaded = await open(path, settings); + let calls = 0; + await assert.rejects(() => loaded.updateGlobal(settingsDocument, (current) => (calls++, current)), /putGlobal to retire its legacy key/); + assert.equal(calls, 0); + // Every path to the write is refused, the global's own key included, and the legacy record survives. + const before = await readFile(path); + await assert.rejects(() => loaded.update(settingsDocument, CultCache.GLOBAL_KEY, () => ({ theme: "lost" })), /putGlobal to retire its legacy key/); + assert.deepEqual([...(await readFile(path))], [...before]); + assert.deepEqual(readSingleFileStore(path).envelopes.map((e) => e.key), ["old-settings"]); + await loaded.putGlobal(settingsDocument, { theme: "y" }); + assert.equal((await loaded.updateGlobal(settingsDocument, (current) => ({ theme: `${current!.theme}z` }))).outcome, "updated"); + assert.deepEqual(loaded.getGlobal(settingsDocument), { theme: "yz" }); + await assert.rejects(() => cache.update(settingsDocument, "not-global", () => ({ theme: "q" })), /must use key/); + }); +}); + +test("a legacy global that a pull loads while apply awaits is refused at the write, and its legacy record survives", async () => { + await withDirectory(async (path) => { + const settings = defineDocumentRegistry(settingsDocument); + const cache = await open(path, settings); + const legacy = await open(path, defineDocumentRegistry(plainSettingsDocument)); + await assert.rejects( + () => + cache.update(settingsDocument, CultCache.GLOBAL_KEY, async () => { + await legacy.put(plainSettingsDocument, "old-settings", { theme: "x" }); + await cache.pullAllBackingStores(); + return { theme: "lost" }; + }), + /putGlobal to retire its legacy key/, + ); + assert.deepEqual(readSingleFileStore(path).envelopes.map((e) => e.key), ["old-settings"]); + }); +}); + +test("the ledger holds under contention: the stored value is the number of updated outcomes", async () => { + await withDirectory(async (path) => { + const caches = await Promise.all([open(path), open(path), open(path)]); + const results = await Promise.all( + caches.map(async (cache) => { + let updated = 0; + for (let i = 0; i < 25; i++) { + const result = await cache.update(counterDocument, KEY, async (current) => { + await tick(); + return { value: (current?.value ?? 0) + 1 }; + }); + if (result.outcome === "updated") updated++; + } + return updated; + }), + ); + assert.equal(stored(path), results.reduce((sum, n) => sum + n, 0)); + }); +}); + +test("a flush of an unchanged cache rewrites the file byte for byte, and an update does not lose a race to it", async () => { + await withDirectory(async (path) => { + const cache = await open(path); + await cache.put(counterDocument, KEY, { value: 1 }); + await cache.put(counterDocument, "other", { value: 7 }); + const store = new SingleFileMessagePackBackingStore(path); + const before = await readFile(path); + // What CultMesh.flush does: push the whole stored image, twice. + await store.pushAll(await store.pullAll()); + await store.pushAll(await store.pullAll()); + assert.deepEqual(await readFile(path), before); + const result = await cache.update(counterDocument, KEY, async (current) => { + await store.pushAll(await store.pullAll()); + return { value: current!.value + 1 }; + }, { maxAttempts: 1 }); + assert.equal(result.outcome, "updated"); + assert.equal(stored(path), 2); + }); +}); + +test("a changed record is re-stamped later than the one it replaces, and a revert to earlier bytes too", async () => { + await withDirectory(async (path) => { + const store = new SingleFileMessagePackBackingStore(path); + const cache = await open(path); + await cache.put(counterDocument, KEY, { value: 1 }); + const first = readSingleFileStore(path).envelopes[0]!; + const same = await store.push({ ...first, storedAt: "2099-01-01T00:00:00.000Z" }); + assert.equal(same.storedAt, first.storedAt, "identical content keeps its stamp"); + await cache.put(counterDocument, KEY, { value: 2 }); + const second = readSingleFileStore(path).envelopes[0]!; + const reverted = await store.push({ ...first, storedAt: second.storedAt }); + assert.ok(Date.parse(reverted.storedAt) > Date.parse(second.storedAt)); + }); +}); + +test("a push result that is not a parseable stamp is ignored", async () => { + for (const result of [true, {}, "", "garbage", { storedAt: "" }, { storedAt: "garbage" }]) { + const entries: Parameters[0] = []; + const store = { ...memoryStore(entries), push: async (entry: (typeof entries)[number]) => { entries.push(entry); return result as never; } }; + const cache = CultCache.builder().withRegistry(registry).withGenericStore(store).build(); + await cache.put(counterDocument, KEY, { value: 1 }); + const held = cache.getEnvelope(counterDocument, KEY)!.storedAt; + assert.equal(Number.isNaN(Date.parse(held)), false, `held stamp ${held} after a push result of ${JSON.stringify(result)}`); + } +}); + +test("a rewrite of identical content keeps the stamp but writes the writer's type and catalog entry", async () => { + await withDirectory(async (path) => { + const old = defineDocumentType({ type: "update.rn.old", schemaId: "update.rn.v1", schemaName: "update.rn.old", schemaVersion: "update.rn.v1", schema: z.object({ value: z.number().int() }) }); + const renamed = defineDocumentType({ type: "update.rn.new", schemaId: "update.rn.v1", schemaName: "update.rn.new", schemaVersion: "update.rn.v1", schema: z.object({ value: z.number().int() }) }); + await (await open(path, defineDocumentRegistry(old))).put(old, KEY, { value: 1 }); + const before = readSingleFileStore(path).envelopes[0]!; + await (await open(path, defineDocumentRegistry(renamed))).put(renamed, KEY, { value: 1 }); + const after = readSingleFileStore(path).envelopes[0]!; + assert.equal(after.storedAt, before.storedAt, "identical content keeps its stamp"); + assert.equal(after.type, "update.rn.new", "the writer's type is what the file says"); + }); +}); + +test("a rewrite of the same bytes under another schema id is a changed record: stamped later, written under the new id", async () => { + await withDirectory(async (path) => { + const v1 = defineDocumentType({ type: "update.sid", schemaId: "update.sid.v1", schemaName: "update.sid", schemaVersion: "update.sid.v1", schema: z.object({ value: z.number().int() }) }); + const v2 = defineDocumentType({ type: "update.sid", schemaId: "update.sid.v2", schemaName: "update.sid", schemaVersion: "update.sid.v2", compatibleSchemaIds: ["update.sid.v1"], schema: z.object({ value: z.number().int() }) }); + await (await open(path, defineDocumentRegistry(v1))).put(v1, KEY, { value: 1 }); + const before = readSingleFileStore(path).envelopes[0]!; + await (await open(path, defineDocumentRegistry(v2))).put(v2, KEY, { value: 1 }); + const after = readSingleFileStore(path).envelopes[0]!; + assert.ok(Date.parse(after.storedAt) > Date.parse(before.storedAt)); + assert.equal(schemaIdForEnvelope(after), "update.sid.v2"); + }); +}); diff --git a/tests/GameCult.Caching.Tests/ContentVersionVectorTests.cs b/tests/GameCult.Caching.Tests/ContentVersionVectorTests.cs new file mode 100644 index 00000000..7c2cd5a8 --- /dev/null +++ b/tests/GameCult.Caching.Tests/ContentVersionVectorTests.cs @@ -0,0 +1,115 @@ +#nullable enable +using System; +using System.Buffers; +using System.IO; +using System.Linq; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Caching.Tests +{ + // tests/vectors/content-version: the content version of a stored record, the lowercase hex SHA-256 of its persisted encoding + // (variants:ruling:write-set-content-version-declared-ownership), as the reference reads it from a single-file store. The C# + // reference is the only writer: under CULTCACHE_WRITE_VECTORS=1 the store is written and the versions read from it are written + // beside it; every other run opens the committed store.cc and compares what the reference reads to versions.msgpack. Another + // runtime computes the version from the same file and must equal these rows. + public class ContentVersionVectorTests + { + private const string Vector = "content-version"; + + private static readonly Type Item = + EmittedDocumentTypes.Emit("CvItem", "tests.cv_item", "tests.cv_item.v1", new[] { new EmittedDocumentTypes.Field("Text", typeof(string), 0) }); + + private static readonly CultDocumentRegistry Registry = CultDocumentRegistry.ForTypes(new[] { Item }); + + private static readonly (string Key, string Text)[] Records = { ("alpha", "first"), ("beta", "second") }; + + private static byte[] StoreBytes() + { + var directory = Path.Combine(Path.GetTempPath(), $"cultlib-cvv-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + try + { + var path = Path.Combine(directory, "store.cc"); + using (var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry, StoreFlushOnDispose = true })) + { + cache.Clock = () => VectorFiles.StoredAt; + foreach (var (key, text) in Records) + cache.UpsertAsync(Item, EmittedDocumentTypes.New(Item, ("Text", text)), new CultRecordKey(key)).GetAwaiter().GetResult(); + cache.FlushAsync().GetAwaiter().GetResult(); + } + + return File.ReadAllBytes(path); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + // What the reference reads from a store file: [key, version] for each record, sorted by key. + private static (string Key, string Version)[] ReadVersions(byte[] store) + { + var directory = Path.Combine(Path.GetTempPath(), $"cultlib-cvr-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + try + { + var path = Path.Combine(directory, "store.cc"); + File.WriteAllBytes(path, store); + using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }); + return cache.AllStoredDocuments + .Select(stored => (Key: stored.Key.Value, Version: stored.StoredVersion)) + .OrderBy(row => row.Key, StringComparer.Ordinal) + .ToArray(); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + private static byte[] RowsFile((string Key, string Version)[] rows) + { + var buffer = new ArrayBufferWriter(); + var writer = new MessagePackWriter(buffer); + writer.WriteArrayHeader(rows.Length); + foreach (var (key, version) in rows) + { + writer.WriteArrayHeader(2); + writer.Write(key); + writer.Write(version); + } + + writer.Flush(); + return buffer.WrittenSpan.ToArray(); + } + + [Test] + public void TheReferenceReadsTheCommittedVersions() + { + if (VectorFiles.Writing) + { + var store = StoreBytes(); + VectorFiles.Check(Vector, "store.cc", store); + VectorFiles.Check(Vector, "versions.msgpack", RowsFile(ReadVersions(store))); + return; + } + + var committed = VectorFiles.Committed(Vector, "store.cc"); + var rows = ReadVersions(committed); + Assert.That(rows.Select(row => row.Key), Is.EqualTo(Records.Select(record => record.Key)), "the store holds the two records"); + Assert.That(rows.Select(row => row.Version).Distinct().Count(), Is.EqualTo(rows.Length), "two different records, two versions"); + Assert.That(rows.All(row => row.Version.Length == 64 && row.Version == row.Version.ToLowerInvariant()), Is.True, "lowercase hex SHA-256"); + Assert.That(Convert.ToHexString(RowsFile(rows)), Is.EqualTo(Convert.ToHexString(VectorFiles.Committed(Vector, "versions.msgpack"))), "versions.msgpack"); + } + + [Test] + public void TheCommittedStoreIsWhatTheReferenceWrites() + { + if (VectorFiles.Writing) return; + Assert.That(Convert.ToHexString(StoreBytes()), Is.EqualTo(Convert.ToHexString(VectorFiles.Committed(Vector, "store.cc"))), "store.cc"); + } + } +} diff --git a/tests/vectors/content-version/README.md b/tests/vectors/content-version/README.md new file mode 100644 index 00000000..cddd7bff --- /dev/null +++ b/tests/vectors/content-version/README.md @@ -0,0 +1,17 @@ +# content-version vectors + +Written only by the C# reference, `tests/GameCult.Caching.Tests/ContentVersionVectorTests.cs`, with +`CULTCACHE_WRITE_VECTORS=1` (never set in CI). Every other run opens the committed `store.cc`, compares what the reference reads +to `versions.msgpack` and compares the store bytes the reference writes to the file; a changed vector is a reviewed commit. +Other runtimes compute the version of each record of `store.cc` and must equal these rows +(`variants:ruling:write-set-content-version-declared-ownership`). + +RULE: the content version of a stored record is the lowercase hex SHA-256 of its persisted encoding, the MessagePack array +`[key, schemaId, storedAt, payload bytes]` exactly as the store writes the record. + +## Files + +- `store.cc`: a single-file store of one type, `tests.cv_item` (one string slot, `[0: Text]`), holding `alpha` = "first" and + `beta` = "second", stored-at pinned to `2026-01-01T00:00:00Z`. +- `versions.msgpack`: an array of `[key, version]`, sorted by key, the version the reference reads for the record + (`GetStored(key).StoredVersion`). diff --git a/tests/vectors/content-version/store.cc b/tests/vectors/content-version/store.cc new file mode 100644 index 0000000000000000000000000000000000000000..3962430aec5fc575a5c2c70acde762d9a6e046cd GIT binary patch literal 766 zcmcgp!Ait15cQ_NVXtbLq?@Ga&676|u6PlVX)<*SZC9EoF5*X21^oc^CWv3KEdGP+ z-xzmgalI;rxy-zo_vTG*Ud>tM4VnSP&JSCP&sldj-EjltLT13YLC&NUi8hI)lq(IQ zXJwu!(@QPWNdY{!QX?2{QHs=P6(*Gl?J9Zp Date: Fri, 13 Nov 2026 19:55:55 +0000 Subject: [PATCH 89/93] Caching: Update reads version and copy from one durable store read (update-copy-from-stored-bytes) (#170) --- docs/start/README.md | 4 +- .../DirectoryMessagePackBackingStore.cs | 107 ++++--- src/GameCult.Caching/CultCache.cs | 86 ++++- .../HeldInstanceUpdateTests.cs | 131 ++++++++ .../OutsideStoreJudgeTests.cs | 13 + .../GameCult.Caching.Tests/ElementIdTests.cs | 22 ++ tests/GameCult.Caching.Tests/UpdateTests.cs | 296 +++++++++++++++++- 7 files changed, 597 insertions(+), 62 deletions(-) create mode 100644 tests/GameCult.Caching.OutsideStoreTests/HeldInstanceUpdateTests.cs diff --git a/docs/start/README.md b/docs/start/README.md index 58874c39..d877469c 100644 --- a/docs/start/README.md +++ b/docs/start/README.md @@ -72,8 +72,8 @@ public static class FirstCall ## C# update with retry -Change a record you may not be the only one changing. `Update` reads the record, hands your function a private -copy, and commits the result only if the record is still the one it read. If another writer got there first, it +Change a record you may not be the only one changing. `Update` reads the record as its store holds it, hands your function a fresh +copy of it, and commits the result only if the record is still the one it read. If another writer got there first, it reads again and runs your function again, up to five times. The source is [`tests/GameCult.Caching.Tests/UpdateCallTests.cs`](../../tests/GameCult.Caching.Tests/UpdateCallTests.cs); the snippet reuses `Item` from the first call. diff --git a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs index 6f6a4daf..6e0ce6cd 100644 --- a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs +++ b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs @@ -64,48 +64,7 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore var reports = new List(); var loaded = new Dictionary(StringComparer.Ordinal); var foreign = new List(); - for (var attempt = 1; ; attempt++) - { - loaded.Clear(); - reports.Clear(); - foreign.Clear(); - // Only pages named by the manifest read under the lease are loaded; orphaned pages are never loaded. With no - // lock to lease (none created yet, or a store copied without it) a writer may commit mid-load, so an unleased - // load stands only if its manifest is still current; if the manifest moved it reloads, under the lease the - // writer created. A page that fails under an unmoved manifest is corruption, not a race. - using var lease = AcquireCommitLease(wait: true, create: false); - var manifestBytes = ReadManifestBytes(); - var (manifest, _) = ParseManifest(manifestBytes); - Trace($"manifest records={manifest.Records.Length}"); - try - { - if (lease == null) - UnleasedManifestRead?.Invoke(); - LoadRecordPages( - manifest.Records.OrderBy(record => record.Key, StringComparer.Ordinal).ToArray(), - manifest.SchemaCatalog, - loaded, - reports, - foreign); - if (lease != null || !ManifestMoved(manifestBytes)) - { - Trace($"indexed-pages loaded={loaded.Count}"); - break; - } - } - catch (Exception exception) when (lease == null && IsTornGeneration(exception)) - { - // The re-read stays out of the filter: an exception thrown inside a filter is swallowed as false. - if (!ManifestMoved(manifestBytes)) - throw; - if (attempt == UnleasedLoadAttempts) - throw Unsettled(exception); - continue; - } - - if (attempt == UnleasedLoadAttempts) - throw Unsettled(null); - } + LoadManifestPages(null, loaded, reports, foreign, Trace); bool Staged(string key) => _dirtyKeys.ContainsKey(key) || _deletedKeys.ContainsKey(key); var arrived = loaded @@ -136,6 +95,70 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore Trace("publish"); } + // The one load of manifest pages, for the whole store (key null) or one record: under the lease, with the moved-manifest retry. + // It reads only; adopting what it loaded is the caller's. + private void LoadManifestPages( + string? key, + Dictionary loaded, + List reports, + List foreign, + Action trace) + { + for (var attempt = 1; ; attempt++) + { + loaded.Clear(); + reports.Clear(); + foreign.Clear(); + // Only pages named by the manifest read under the lease are loaded; orphaned pages are never loaded. With no + // lock to lease (none created yet, or a store copied without it) a writer may commit mid-load, so an unleased + // load stands only if its manifest is still current; if the manifest moved it reloads, under the lease the + // writer created. A page that fails under an unmoved manifest is corruption, not a race. + using var lease = AcquireCommitLease(wait: true, create: false); + var manifestBytes = ReadManifestBytes(); + var (manifest, _) = ParseManifest(manifestBytes); + trace($"manifest records={manifest.Records.Length}"); + try + { + if (lease == null) + UnleasedManifestRead?.Invoke(); + LoadRecordPages( + manifest.Records.Where(record => key == null || string.Equals(record.Key, key, StringComparison.Ordinal)) + .OrderBy(record => record.Key, StringComparer.Ordinal).ToArray(), + manifest.SchemaCatalog, + loaded, + reports, + foreign); + if (lease != null || !ManifestMoved(manifestBytes)) + { + trace($"indexed-pages loaded={loaded.Count}"); + break; + } + } + catch (Exception exception) when (lease == null && IsTornGeneration(exception)) + { + // The re-read stays out of the filter: an exception thrown inside a filter is swallowed as false. + if (!ManifestMoved(manifestBytes)) + throw; + if (attempt == UnleasedLoadAttempts) + throw Unsettled(exception); + continue; + } + + if (attempt == UnleasedLoadAttempts) + throw Unsettled(null); + } + } + + // The record at a key as its page holds it now, decoded fresh and stamped with VersionOf its manifest payload, the version + // CommitBatch compares. Null when none is there or it is foreign. Touches no entry, hydration, report or foreign list of the + // store, and raises nothing. + internal override CultStoredDocument? ReadDurable(string key) => Held(() => + { + var loaded = new Dictionary(StringComparer.Ordinal); + LoadManifestPages(key, loaded, new List(), new List(), _ => { }); + return loaded.TryGetValue(key, out var stored) ? stored : null; + }); + // Variants wait for the directory store's own cut; until then it refuses one rather than persist a resolved view as a plain page. private static void RefuseVariant(CultStoredDocument entry) { diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index dc0c8b99..303d3737 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -2682,34 +2682,66 @@ namespace GameCult.Caching } /// - /// Reads the record at , applies to a private copy of it, and commits the result only - /// if the record is still the one that was read; when another writer got there first it re-reads and runs - /// again, up to times. The write is durable when Update returns. + /// Reads the record at as its home store holds it, applies to a fresh copy of it, and + /// commits the result only if the record is still the one that was read; when another writer got there first it re-reads and runs + /// again, up to times. The write is durable when Update returns. /// /// - /// may run up to times. It gets a private copy (null when the key holds - /// nothing), which it may change and return. It must not write to the cache or have other side effects. Return null to write - /// nothing. A store with staged blind writes refuses Update until it is flushed, as it refuses any conditional commit. The cache - /// needs a codec to copy with. Running out of attempts returns ; every other failure throws. + /// may run up to times. It gets the record as the store that holds it has it now, read from every home + /// of T and its routed subtypes and decoded afresh for this attempt (null when the key holds nothing), which it may change and return. A record held by two stores, or an apply that changes the record's home store, throws. A change made in place to an + /// instance the cache holds is neither seen nor written. It must not write to the cache or have other side effects. Return null to write + /// nothing. A store with staged blind writes refuses Update until it is flushed, as it refuses any conditional commit. A cache with no + /// store attached copies its held instance and needs a codec; a store kind outside the library cannot serve Update (). Running out of attempts returns ; every other failure throws. /// public CultUpdateResult Update(CultRecordKey key, Func apply, int maxAttempts = 5) where T : class { if (apply == null) throw new ArgumentNullException(nameof(apply)); if (maxAttempts < 1) throw new ArgumentOutOfRangeException(nameof(maxAttempts), maxAttempts, "At least one attempt is required."); - var codec = _codec ?? throw new InvalidOperationException("This cache has no codec; create it with CultCacheMessagePack.Create."); for (var attempt = 1; ; attempt++) { - var (version, copy) = Held(() => + var (version, copy, source) = Held(() => { - if (!_entries.TryGetValue(key.Value, out var held)) - return ((string?)null, (T?)null); - if (held.Variant != null || held.Document is not T) + if (_stores.Count == 0) + { + // No store: the held entry is the record. + _entries.TryGetValue(key.Value, out var held); + var codec = _codec ?? throw new InvalidOperationException("This cache has no codec; create it with CultCacheMessagePack.Create."); + if (held == null) + return ((string?)null, (T?)null, (CacheBackingStore?)null); + if (held.Variant != null || held.Document is not T) + throw new InvalidOperationException($"Record {key.Value} is a variant or not a {typeof(T).Name}; Update cannot copy it."); + return (held.StoredVersion, (T?)codec.Clone(held.Document, held.Descriptor.DocumentType), null); + } + + // The store with the key is among the homes of T and its routed subtypes; the cache's own view of the key may be stale, so it is not asked. + var homes = CandidateHomes(typeof(T)); + if (homes.Length == 0) + throw new InvalidOperationException("A conditional batch names its home store through a record it upserts or removes."); + CacheBackingStore? holder = null; + CultStoredDocument? durable = null; + foreach (var candidate in homes) + { + if (candidate.ReadDurable(key.Value) is not { } found) + continue; + if (holder != null) + throw new InvalidOperationException($"Record {key.Value} is held by two stores, {holder} and {candidate}; Update cannot choose."); + holder = candidate; + durable = found; + } + + if (durable == null) + return ((string?)null, (T?)null, (CacheBackingStore?)null); + if (durable.Variant != null || durable.Document is not T typed) throw new InvalidOperationException($"Record {key.Value} is a variant or not a {typeof(T).Name}; Update cannot copy it."); - return (held.StoredVersion, (T?)codec.Clone(held.Document, held.Descriptor.DocumentType)); + CultElementIds.Plan(durable.Descriptor.DocumentType, durable.Document, key.Value, deterministic: true).Apply(); + return (durable.StoredVersion, typed, holder); }); var next = apply(copy); if (next == null) return new CultUpdateResult(CultUpdateOutcome.Declined, Get(key)); + if (source != null && Held(() => Home(next.GetType())) is var landing && landing != source) + throw new InvalidOperationException( + $"Update of {key.Value} would move the record from {source} to {landing?.ToString() ?? "memory"}; remove it and write it at its new home."); CultCommitOutcome outcome; try { @@ -2728,9 +2760,11 @@ namespace GameCult.Caching return new CultUpdateResult(CultUpdateOutcome.Updated, next); if (outcome != CultCommitOutcome.Mismatch) throw new InvalidOperationException($"Update of {key.Value} was refused: {outcome}."); - Held(() => { Home(next.GetType())?.PullAll(); return true; }); if (attempt >= maxAttempts) + { + Held(() => { Home(next.GetType())?.PullAll(); return true; }); return new CultUpdateResult(CultUpdateOutcome.LostRace, Get(key)); + } } } @@ -3751,6 +3785,18 @@ namespace GameCult.Caching return changes; } + // Every store a record of this type, or of a registered subtype of it, can live in. + private CacheBackingStore[] CandidateHomes(Type type) + { + var homes = new HashSet(); + if (Home(type) is { } own) + homes.Add(own); + foreach (var descriptor in _registry.AllDescriptors) + if (type.IsAssignableFrom(descriptor.DocumentType) && Home(descriptor.DocumentType) is { } routed) + homes.Add(routed); + return _stores.Select(entry => entry.Store).Where(homes.Contains).ToArray(); + } + // The most derived routed home type assignable from the document type, else the untyped store. private CacheBackingStore? Home(Type documentType) { @@ -4027,6 +4073,15 @@ namespace GameCult.Caching internal void UseRegistry(CultDocumentRegistry? registry) => _registry = registry; public abstract void PullAll(); + + /// + /// The record at as this store holds it now, decoded as a load decodes it by this call (a new instance nobody + /// else holds) and stamped with the version this store's commit compares. Null when the store holds nothing there or holds it foreign. + /// It changes none of the store's entries, staged writes or reports and raises nothing. Internal because only this library can stamp a version. + /// + internal virtual CultStoredDocument? ReadDurable(string key) => + throw new NotSupportedException($"{this} cannot read one record, which Update needs."); + public abstract void Push(CultStoredDocument entry); public abstract void Delete(CultStoredDocument entry); public void PushAll() => PushAll(soft: false); @@ -5173,6 +5228,9 @@ namespace GameCult.Caching return ToStoredDocument(record, durable.Version(key), durable.Snapshot.SchemaCatalog, DeserializeMarked, out _); } + // Update's read: the durable record at the key, decoded afresh, stamped with the version CommitBatch compares. + internal override CultStoredDocument? ReadDurable(string key) => Held(() => DurableAt(ReadSnapshot(), key)); + // The file now holds every record written, under the id and at the storedAt the write gave it, and the version of those bytes: // the store's entries and the cache's say so. A copied record is on disk as the file had it, so nothing learns anything new about it: in particular the // ids it minted at load are still in memory only. diff --git a/tests/GameCult.Caching.OutsideStoreTests/HeldInstanceUpdateTests.cs b/tests/GameCult.Caching.OutsideStoreTests/HeldInstanceUpdateTests.cs new file mode 100644 index 00000000..6b02b72f --- /dev/null +++ b/tests/GameCult.Caching.OutsideStoreTests/HeldInstanceUpdateTests.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.IO; +using GameCult.Caching.MessagePack; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Caching.OutsideStoreTests +{ + // variants:follow_up:update-copy-from-stored-bytes: Update takes its version and its copy from one durable read of the home + // store, never from the held instance. Consumer side: public API only, so a change made in place to an instance the cache + // hands out can never be committed as if it had been read. + public class HeldInstanceUpdateTests + { + [CultDocument("tests.held_counter", "tests.held_counter.v1")] + [MessagePackObject] + public sealed class HeldCounter + { + [Key(0)] [CultName] public string Name { get; set; } = string.Empty; + [Key(1)] public int Value { get; set; } + } + + private static readonly CultDocumentRegistry Registry = CultDocumentRegistry.ForTypes(new[] { typeof(HeldCounter) }); + private static readonly CultRecordKey Key = new("counter"); + + private string _directory = string.Empty; + + [SetUp] + public void CreateDirectory() + { + _directory = Path.Combine(Path.GetTempPath(), $"cultlib-held-update-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_directory); + } + + [TearDown] + public void DeleteDirectory() + { + if (Directory.Exists(_directory)) + Directory.Delete(_directory, recursive: true); + } + + private static CultCache Open(string path, bool directory) + { + var cache = new CultCache(Registry, CultCacheMessagePack.CreateCodec(Registry)); + cache.AddBackingStore(directory ? new DirectoryMessagePackBackingStore(path) : new SingleFileMessagePackBackingStore(path)); + cache.PullAllBackingStoresAsync().GetAwaiter().GetResult(); + return cache; + } + + private string Seed(bool directory, int value) + { + var path = Path.Combine(_directory, "held.cc"); + using var cache = Open(path, directory); + Assert.That(cache.Commit(batch => batch.Upsert(new HeldCounter { Name = "counter", Value = value }, new CultRecordHandle(Key))), Is.True); + return path; + } + + private static int StoredValue(string path, bool directory) + { + using var reader = Open(path, directory); + return reader.Get(Key)!.Value; + } + + private static HeldCounter Plus(HeldCounter? counter, int by) + { + counter!.Value += by; + return counter; + } + + [TestCase(false)] + [TestCase(true)] + public void InPlaceChangeToHeldInstanceIsNeitherAppliedNorPersisted(bool directory) + { + var path = Seed(directory, 1); + using var cache = Open(path, directory); + cache.Get(Key)!.Value = 999; + var seen = new List(); + + var result = cache.Update(Key, counter => { seen.Add(counter!.Value); return Plus(counter, 1); }); + + Assert.That(result.Outcome, Is.EqualTo(CultUpdateOutcome.Updated)); + Assert.That(seen, Is.EqualTo(new[] { 1 }), "apply saw the in-place change"); + Assert.That(result.Document!.Value, Is.EqualTo(2)); + Assert.That(StoredValue(path, directory), Is.EqualTo(2), "the in-place 999 was persisted"); + } + + [TestCase(false)] + [TestCase(true)] + public void InPlaceChangeToAPreviousResultIsNotCommitted(bool directory) + { + var path = Seed(directory, 1); + using var cache = Open(path, directory); + var first = cache.Update(Key, counter => Plus(counter, 1)); + Assert.That(first.Outcome, Is.EqualTo(CultUpdateOutcome.Updated)); + first.Document!.Value = 999; + var seen = new List(); + + var second = cache.Update(Key, counter => { seen.Add(counter!.Value); return Plus(counter, 1); }); + + Assert.That(second.Outcome, Is.EqualTo(CultUpdateOutcome.Updated)); + Assert.That(seen, Is.EqualTo(new[] { 2 }), "apply ran more than once or saw the in-place change"); + Assert.That(StoredValue(path, directory), Is.EqualTo(3)); + } + + [TestCase(false)] + [TestCase(true)] + public void DeclineAfterInPlaceChangeWritesNothing(bool directory) + { + var path = Seed(directory, 1); + using var cache = Open(path, directory); + cache.Get(Key)!.Value = 999; + var before = Snapshot(path); + + Assert.That(cache.Update(Key, _ => null).Outcome, Is.EqualTo(CultUpdateOutcome.Declined)); + + Assert.That(Snapshot(path), Is.EqualTo(before)); + Assert.That(StoredValue(path, directory), Is.EqualTo(1)); + } + + // Every file of the store: the single file, or the manifest and its pages. + private static Dictionary Snapshot(string path) + { + var files = new Dictionary { [path] = File.ReadAllBytes(path) }; + var pages = path + ".records"; + if (Directory.Exists(pages)) + foreach (var file in Directory.GetFiles(pages, "*", SearchOption.AllDirectories)) + files[file] = File.ReadAllBytes(file); + return files; + } + } +} diff --git a/tests/GameCult.Caching.OutsideStoreTests/OutsideStoreJudgeTests.cs b/tests/GameCult.Caching.OutsideStoreTests/OutsideStoreJudgeTests.cs index bd2fe047..86949fa0 100644 --- a/tests/GameCult.Caching.OutsideStoreTests/OutsideStoreJudgeTests.cs +++ b/tests/GameCult.Caching.OutsideStoreTests/OutsideStoreJudgeTests.cs @@ -364,5 +364,18 @@ namespace GameCult.Caching.OutsideStoreTests Assert.That(() => cache.Commit(batch => batch.Remove(K)), Throws.InstanceOf(), "removal"); Assert.That(disk.Records["k"], Is.EqualTo(before)); } + + // Only the library can stamp a version on a decoded record, so a store kind outside it cannot read one honestly: Update refuses before apply. + [Test] + public void UpdateOverAnOutsideStoreIsNotSupported() + { + var disk = new Disk(); + PutOnDisk(disk, AB, "k", new OutA { Name = "k", Value = 1 }); + using var cache = Open(disk, AB); + var calls = 0; + + Assert.That(() => cache.Update(K, a => { calls++; return a; }), Throws.TypeOf()); + Assert.That(calls, Is.EqualTo(0), "apply ran for a store that cannot serve Update"); + } } } diff --git a/tests/GameCult.Caching.Tests/ElementIdTests.cs b/tests/GameCult.Caching.Tests/ElementIdTests.cs index a6c68c94..d40ffe25 100644 --- a/tests/GameCult.Caching.Tests/ElementIdTests.cs +++ b/tests/GameCult.Caching.Tests/ElementIdTests.cs @@ -224,6 +224,28 @@ namespace GameCult.Caching.Tests Assert.That(AllIds(reread), Is.EqualTo(first), "the ids minted at load are the ids written, and stay through repeated writes"); } + // Update reads the store's bytes, which hold no ids; it mints the ids the load minted, so the write keeps the ids the cache showed. + [Test] + public void UpdateOfAPreIdRecordPersistsTheIdsTheLoadMinted() + { + var path = PathOf("old.cc"); + WritePreIdStore(path, null, ("a", "a", 2)); + var key = new CultRecordKey("a"); + + string[] shown; + using (var cache = Open(path)) + { + shown = AllIds(cache.Get(key)!); + Assert.That(shown, Has.All.Not.Empty); + var result = cache.Update(key, deck => { deck!.Name = "a2"; return deck; }); + Assert.That(result.Outcome, Is.EqualTo(CultUpdateOutcome.Updated)); + } + + var written = MessagePackSerializer.Deserialize(DiskRecord(path, "a").Payload); + Assert.That(written.Name, Is.EqualTo("a2")); + Assert.That(AllIds(written), Is.EqualTo(shown), "the write replaced the ids the cache showed with random ones"); + } + [Test] public void MintElementIdsRewritesEveryPreIdRecordAndNothingElse() { diff --git a/tests/GameCult.Caching.Tests/UpdateTests.cs b/tests/GameCult.Caching.Tests/UpdateTests.cs index 0dd96bad..63462ba6 100644 --- a/tests/GameCult.Caching.Tests/UpdateTests.cs +++ b/tests/GameCult.Caching.Tests/UpdateTests.cs @@ -19,7 +19,7 @@ namespace GameCult.Caching.Tests { private static readonly CultRecordKey Key = new("counter"); private static readonly CultDocumentRegistry Registry = - CultDocumentRegistry.ForTypes(new[] { typeof(UpdateCounter), typeof(UpdateTally), typeof(UpdateGear) }); + CultDocumentRegistry.ForTypes(new[] { typeof(UpdateCounter), typeof(UpdateTally), typeof(UpdateGear), typeof(UpdateBase), typeof(UpdateDerived) }); private string _directory = string.Empty; @@ -78,16 +78,22 @@ namespace GameCult.Caching.Tests using var a = Open(path, directory); using var b = Open(path, directory); var applied = 0; + // Each round starts both writers together: an attempt reads fresh from the store, so only overlapping read-commit windows lose. + var round = new Barrier(2); var racers = new[] { a, b }.Select(cache => Task.Run(() => { var results = new List(); for (var i = 0; i < n; i++) + { + round.SignalAndWait(); results.Add(cache.Update(Key, counter => { Interlocked.Increment(ref applied); return Plus(counter, 1); }, maxAttempts: 200).Outcome); + } + return results; })).ToArray(); Assert.That(Task.WaitAll(racers, TimeSpan.FromMinutes(2)), Is.True, "the racers did not finish"); @@ -172,6 +178,193 @@ namespace GameCult.Caching.Tests Assert.That(StoredValue(path, false), Is.EqualTo(1005)); } + // No publication in the middle of an Update: a rival's write that cost this cache an attempt is never published on its own, + // only the value this Update commits (a pull after every lost attempt would publish both). + [TestCase(false)] + [TestCase(true)] + public void LostAttemptPublishesNothingMidUpdate(bool directory) + { + var path = PathOf("midupdate.cc"); + Seed(path, directory, 1); + using var cache = Open(path, directory); + using var rival = Open(path, directory); + var published = new List(); + using var subscription = cache.Watch().Subscribe(change => published.Add(change.Document!.Value)); + var calls = 0; + + var result = cache.Update(Key, counter => + { + if (++calls == 1) + Rival(rival, 5); + return Plus(counter, 1); + }); + + Assert.That(result.Outcome, Is.EqualTo(CultUpdateOutcome.Updated)); + Assert.That(calls, Is.EqualTo(2)); + Assert.That(published, Is.EqualTo(new[] { 6 }), "the rival's 5 was published in the middle of the Update"); + } + + // The directory store reads only the key's own page, so a damaged page of another record does not stop an Update. + [Test] + public void DirectoryUpdateReadsOnlyItsOwnPage() + { + var path = PathOf("pages.cc"); + var other = new CultRecordKey("other"); + using (var seed = Open(path, true)) + Assert.That(seed.Commit(batch => batch.Upsert(new UpdateCounter { Name = "other", Value = 9 }, new CultRecordHandle(other))), Is.True); + var otherPages = Directory.GetFiles(path + ".records", "*.msgpack"); + Assert.That(otherPages, Has.Length.EqualTo(1)); + Seed(path, true, 1); + using var cache = Open(path, true); + File.WriteAllBytes(otherPages[0], new byte[] { 1, 2, 3, 4, 5 }); + + var result = cache.Update(Key, counter => Plus(counter, 1)); + + Assert.That(result.Outcome, Is.EqualTo(CultUpdateOutcome.Updated)); + Assert.That(result.Document!.Value, Is.EqualTo(2)); + } + + // The durable read is not a load: it leaves the store's foreign list, its migration reports and what the cache publishes alone. + [TestCase(false)] + [TestCase(true)] + public void ReadDurableLeavesTheStoreViewAlone(bool directory) + { + var path = PathOf("view.cc"); + Seed(path, directory, 1); + using (var writer = Open(path, directory)) + Assert.That(writer.Commit(batch => batch.Upsert(new UpdateGear { Name = "gear", Power = 3 }, new CultRecordHandle(new CultRecordKey("gear")))), Is.True); + var narrow = CultDocumentRegistry.ForTypes(new[] { typeof(UpdateCounter) }); + using var cache = new CultCache(narrow); + cache.AddBackingStore(directory ? new DirectoryMessagePackBackingStore(path) : new SingleFileMessagePackBackingStore(path)); + using var rival = Open(path, directory); + var store = cache.BackingStores.Single(); + var foreign = store.ForeignRecords; + var reports = store.LastSchemaMigrationReports; + Assert.That(foreign.Select(record => record.Key), Is.EqualTo(new[] { "gear" }), "the fixture holds a foreign record"); + Assert.That(reports, Is.Not.Empty, "the fixture holds a migration report"); + var foreignKeys = foreign.Select(record => (record.Key, record.SchemaId, record.StoredAt)).ToArray(); + var reportKinds = reports.Select(report => (report.PersistedSchemaName, report.Kind)).ToArray(); + var changes = 0; + using var subscription = cache.Watch().Subscribe(_ => changes++); + var calls = 0; + + var result = cache.Update(Key, counter => + { + if (++calls == 1) + Rival(rival, 5); + return Plus(counter, 1); + }); + + Assert.That(result.Outcome, Is.EqualTo(CultUpdateOutcome.Updated)); + Assert.That(calls, Is.EqualTo(2), "the Update reread the key"); + Assert.That(store.ForeignRecords, Is.SameAs(foreign)); + Assert.That(store.LastSchemaMigrationReports, Is.SameAs(reports)); + Assert.That(store.ForeignRecords.Select(record => (record.Key, record.SchemaId, record.StoredAt)), Is.EqualTo(foreignKeys)); + Assert.That(store.LastSchemaMigrationReports.Select(report => (report.PersistedSchemaName, report.Kind)), Is.EqualTo(reportKinds)); + Assert.That(changes, Is.EqualTo(1), "only the committed write was published"); + } + + // Routing: store A is home to UpdateBase, store B to UpdateDerived : UpdateBase. Land judges a derived record at B. + private CultCache OpenRouted() + { + var cache = new CultCache(Registry); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(PathOf("a.cc")), typeof(UpdateBase)); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(PathOf("b.cc")), typeof(UpdateDerived)); + return cache; + } + + private CultCache OpenOneRouted(string file, Type home) + { + var cache = new CultCache(Registry); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(PathOf(file)), home); + return cache; + } + + private byte[]? BytesOf(string file) => File.Exists(PathOf(file)) ? File.ReadAllBytes(PathOf(file)) : null; + + private static readonly CultRecordHandle BaseHandle = new(Key); + + private static void RivalDerived(CultCache cache, int value) => + Assert.That(cache.Commit(batch => batch.Upsert(new UpdateDerived { Name = "counter", Value = value }, BaseHandle)), Is.True); + + [Test] + public void RoutedSubtypeStaleCacheConverges() + { + using var cache = OpenRouted(); + using var rival = OpenRouted(); + RivalDerived(rival, 1); + var seen = new List(); + + var result = cache.Update(Key, document => + { + seen.Add((document as UpdateDerived)?.Value); + var derived = (UpdateDerived)document!; + derived.Value += 1; + return derived; + }); + + Assert.That(seen, Is.EqualTo(new int?[] { 1 }), "apply did not see the rival's record in its home store, once"); + Assert.That(result.Outcome, Is.EqualTo(CultUpdateOutcome.Updated)); + using var reader = OpenRouted(); + Assert.That(((UpdateDerived)reader.Get(Key)!).Value, Is.EqualTo(2)); + } + + [Test] + public void RoutedSubtypeCreateLandsInItsHome() + { + using (var cache = OpenRouted()) + { + var calls = 0; + var result = cache.Update(Key, document => + { + calls++; + Assert.That(document, Is.Null); + return new UpdateDerived { Name = "counter", Value = 1 }; + }); + Assert.That(calls, Is.EqualTo(1)); + Assert.That(result.Outcome, Is.EqualTo(CultUpdateOutcome.Updated)); + } + + using (var inB = OpenOneRouted("b.cc", typeof(UpdateDerived))) + Assert.That(inB.Get(Key), Is.InstanceOf()); + using (var inA = OpenOneRouted("a.cc", typeof(UpdateBase))) + Assert.That(inA.Get(Key), Is.Null, "the record landed in the wrong store"); + } + + [Test] + public void MovingTheRecordToAnotherHomeRefuses() + { + using (var rival = OpenRouted()) + RivalDerived(rival, 1); + var before = (A: BytesOf("a.cc"), B: BytesOf("b.cc")); + using var cache = OpenRouted(); + var calls = 0; + + var refusal = Assert.Throws(() => + cache.Update(Key, _ => { calls++; return new UpdateBase { Name = "counter" }; }))!; + + Assert.That(calls, Is.EqualTo(1), "a refusal was retried"); + Assert.That(refusal.Message, Does.Contain("a.cc").And.Contain("b.cc").And.Contain("move")); + Assert.That(BytesOf("a.cc"), Is.EqualTo(before.A)); + Assert.That(BytesOf("b.cc"), Is.EqualTo(before.B)); + } + + [Test] + public void ARecordHeldByTwoStoresRefusesBeforeApply() + { + using var cache = OpenRouted(); + using (var inA = OpenOneRouted("a.cc", typeof(UpdateBase))) + Assert.That(inA.Commit(batch => batch.Upsert(new UpdateBase { Name = "counter" }, BaseHandle)), Is.True); + using (var inB = OpenOneRouted("b.cc", typeof(UpdateDerived))) + Assert.That(inB.Commit(batch => batch.Upsert(new UpdateDerived { Name = "counter", Value = 1 }, BaseHandle)), Is.True); + var calls = 0; + + var refusal = Assert.Throws(() => cache.Update(Key, document => { calls++; return document; }))!; + + Assert.That(calls, Is.EqualTo(0)); + Assert.That(refusal.Message, Does.Contain("held by two stores")); + } + [Test] public void NullFromApplyDeclines() { @@ -219,8 +412,19 @@ namespace GameCult.Caching.Tests var calls = 0; Func apply = counter => { calls++; return Plus(counter, 1); }; - using (var codecLess = Open(path, false, codec: false)) - Assert.That(() => codecLess.Update(Key, apply), Throws.InvalidOperationException); + // A cache with no store copies its held instance, so it needs a codec. + using (var storeLess = new CultCache(Registry)) + { + storeLess.Commit(batch => batch.Upsert(new UpdateCounter { Name = "counter", Value = 1 }, new CultRecordHandle(Key))); + Assert.That(() => storeLess.Update(Key, apply), Throws.InvalidOperationException.With.Message.Contains("no codec")); + } + + // Stores are attached, but none is home to the type: refused before apply. + using (var noHome = new CultCache(Registry, CultCacheMessagePack.CreateCodec(Registry))) + { + noHome.AddBackingStore(new SingleFileMessagePackBackingStore(PathOf("nohome.cc")), typeof(UpdateTally)); + Assert.That(() => noHome.Update(Key, apply), Throws.InvalidOperationException.With.Message.Contains("names its home store")); + } using (var cache = Open(path, false)) { Assert.That(() => cache.Update(Key, apply, maxAttempts: 0), Throws.TypeOf()); @@ -241,7 +445,7 @@ namespace GameCult.Caching.Tests before = File.ReadAllBytes(path); var gearCalls = 0; using (var cache = Open(path, false)) - Assert.That(() => cache.Update(new CultRecordKey("gear-big"), gear => { gearCalls++; return gear; }), Throws.InvalidOperationException); + Assert.That(() => cache.Update(new CultRecordKey("gear-big"), gear => { gearCalls++; return gear; }), Throws.InvalidOperationException.With.Message.Contains("Update cannot copy it")); Assert.That(gearCalls, Is.EqualTo(0), "apply ran for a variant"); Assert.That(calls, Is.EqualTo(0), "apply ran for a refused call"); Assert.That(File.ReadAllBytes(path), Is.EqualTo(before)); @@ -479,6 +683,73 @@ namespace GameCult.Caching.Tests Assert.That(cache.Get(Key)!.Value, Is.EqualTo(200)); } + [TestCase(false)] + [TestCase(true)] + public void StaleCacheFirstAttemptSeesTheStore(bool directory) + { + var path = PathOf("stale.cc"); + Seed(path, directory, 1); + using var a = Open(path, directory); + using var b = Open(path, directory); + Assert.That(b.Get(Key)!.Value, Is.EqualTo(1)); + Rival(a, 7); + var seen = new List(); + + var result = b.Update(Key, counter => { seen.Add(counter!.Value); return Plus(counter, 1); }); + + Assert.That(result.Outcome, Is.EqualTo(CultUpdateOutcome.Updated)); + Assert.That(seen, Is.EqualTo(new[] { 7 }), "the first attempt read the cache's stale copy"); + Assert.That(StoredValue(path, directory), Is.EqualTo(8)); + } + + // The durable read is not a load: it leaves the store's view of what the cache holds alone, so a later pull still delivers the rival's write. + [TestCase(false)] + [TestCase(true)] + public void ADeclinedUpdateDoesNotHideTheRivalsWriteFromALaterPull(bool directory) + { + var path = PathOf("hidden.cc"); + Seed(path, directory, 1); + using var a = Open(path, directory); + using var b = Open(path, directory); + Assert.That(b.Get(Key)!.Value, Is.EqualTo(1)); + Rival(a, 7); + + Assert.That(b.Update(Key, _ => null).Outcome, Is.EqualTo(CultUpdateOutcome.Declined)); + Assert.That(b.Get(Key)!.Value, Is.EqualTo(1), "a declined Update changed what the cache holds"); + b.PullAllBackingStoresAsync().GetAwaiter().GetResult(); + + Assert.That(b.Get(Key)!.Value, Is.EqualTo(7)); + } + + [Test] + public void CodecLessStoreBackedCacheUpdates() + { + var path = PathOf("codecless.cc"); + Seed(path, false, 1); + var calls = 0; + using (var cache = Open(path, false, codec: false)) + Assert.That(cache.Update(Key, counter => { calls++; return Plus(counter, 1); }).Outcome, Is.EqualTo(CultUpdateOutcome.Updated)); + + Assert.That(calls, Is.EqualTo(1)); + Assert.That(StoredValue(path, false), Is.EqualTo(2)); + } + + [TestCase(false)] + [TestCase(true)] + public void AStoreHoldingNothingAtTheKeyIsAbsentEvenIfTheCacheHoldsIt(bool directory) + { + var path = PathOf("gone.cc"); + Seed(path, directory, 5); + using var a = Open(path, directory); + using var b = Open(path, directory); + Assert.That(b.Get(Key), Is.Not.Null); + Assert.That(a.Commit(batch => batch.Remove(Key)), Is.True); + var seen = new List(); + + Assert.That(b.Update(Key, counter => { seen.Add(counter); return null; }).Outcome, Is.EqualTo(CultUpdateOutcome.Declined)); + Assert.That(seen, Is.EqualTo(new UpdateCounter?[] { null })); + } + [CultDocument("tests.update_counter", "tests.update_counter.v1")] [MessagePackObject(AllowPrivate = true)] internal sealed class UpdateCounter @@ -511,5 +782,22 @@ namespace GameCult.Caching.Tests [Key(1)] public int Power; } + + [CultDocument("tests.update_base", "tests.update_base.v1")] + [MessagePackObject(AllowPrivate = true)] + internal class UpdateBase + { + [Key(0)] + [CultName] + public string Name = string.Empty; + } + + [CultDocument("tests.update_derived", "tests.update_derived.v1")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class UpdateDerived : UpdateBase + { + [Key(1)] + public int Value; + } } } From afab2d5a12d8c3c1ab5f46bd16572c8d3da0685c Mon Sep 17 00:00:00 2001 From: Libby Date: Sat, 14 Nov 2026 00:54:00 +0000 Subject: [PATCH 90/93] cultcache-rs: update-with-retry (update-with-retry-rs) (#171) --- docs/start/README.md | 32 +- packages/cultcache-rs/CHANGELOG.md | 8 + packages/cultcache-rs/Cargo.lock | 72 ++ packages/cultcache-rs/Cargo.toml | 1 + packages/cultcache-rs/README.md | 10 +- packages/cultcache-rs/src/lib.rs | 445 ++++++++-- .../tests/content_version_vector.rs | 26 + packages/cultcache-rs/tests/first_call.rs | 58 +- packages/cultcache-rs/tests/slot_readonly.rs | 2 +- packages/cultcache-rs/tests/union_parity.rs | 2 +- packages/cultcache-rs/tests/unique_index.rs | 4 +- packages/cultcache-rs/tests/update.rs | 770 ++++++++++++++++++ packages/cultmesh-rs/Cargo.lock | 1 + packages/cultnet-rs/Cargo.lock | 1 + .../src/idunn_runtime_provider/tests.rs | 1 + packages/cultnet-rs/src/service_identity.rs | 4 +- 16 files changed, 1350 insertions(+), 87 deletions(-) create mode 100644 packages/cultcache-rs/tests/content_version_vector.rs create mode 100644 packages/cultcache-rs/tests/update.rs diff --git a/docs/start/README.md b/docs/start/README.md index d877469c..a86f4732 100644 --- a/docs/start/README.md +++ b/docs/start/README.md @@ -18,7 +18,7 @@ this page. | --- | --- | --- | --- | --- | --- | | **C#** | [first call, tested](#c-first-call); [update, tested](#c-update-with-retry) | [package](../../packages/cultmath/README.md); snippet not yet | [package](../../src/GameCult.Geometry/README.md); snippet not yet | [first call, tested](#c-cultnet-first-call); [package](../../src/GameCult.Networking) | [quickstart](../../src/GameCult.Mesh/docs/durable-node-quickstart.md); run by `scripts/verify-eve-getting-started.ps1` | | **TypeScript** | [first call, tested](#typescript-first-call); [update, tested](#typescript-update-with-retry) | not yet | not yet | [first call, tested](#typescript-cultnet-first-call); [package](../../packages/cultnet-ts/README.md) | [package](../../packages/cultmesh-ts/README.md); snippet not yet; [recipe: two-client .io game](io-game.md), tested with two live clients, browser page not yet run | -| **Rust** | [first call, tested](#rust-first-call) | [native core](../../packages/cultmath/native/cultmath-core); snippet not yet | not yet | [first call, tested](#rust-cultnet-first-call); [package](../../packages/cultnet-rs/README.md), [examples](../../packages/cultnet-rs/examples) | [package](../../packages/cultmesh-rs); snippet not yet | +| **Rust** | [first call, tested](#rust-first-call); [update, tested](#rust-update-with-retry) | [native core](../../packages/cultmath/native/cultmath-core); snippet not yet | not yet | [first call, tested](#rust-cultnet-first-call); [package](../../packages/cultnet-rs/README.md), [examples](../../packages/cultnet-rs/examples) | [package](../../packages/cultmesh-rs); snippet not yet | | **Python** | [first call, tested](#python-first-call) | not yet | not yet | [first call, tested](#python-cultnet-first-call); [package](../../packages/cultnet-py/README.md) | [package](../../packages/cultmesh-py/README.md); snippet not yet | For the whole @@ -220,6 +220,36 @@ fn first_call() -> anyhow::Result<()> { } ``` +## Rust update with retry + +`update` changes a record from what the store holds now and commits only if it is still that record. The source is the +second region of +[`packages/cultcache-rs/tests/first_call.rs`](../../packages/cultcache-rs/tests/first_call.rs), which uses the `Item` of +the first call above; a test in that file fails if the block stops matching the file. + +```rust +fn restock(path: &Path) -> anyhow::Result> { + let mut cache = CultCache::new(); + cache.register_entry_type::()?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(path))?; + cache.pull_all_backing_stores()?; + cache.update::("item:potion", |item| { + // The store's copy of the record, or nothing if it has none; this may run again on a lost race. + let mut item = item.unwrap_or(Item { + name: "Potion".to_string(), + category: "Consumable".to_string(), + value: 0, + }); + item.value += 10; + Some(item) + }) +} +``` + +The write is durable when `update` returns. A lost race re-runs the closure, so keep it free of side effects; when the +attempts run out (five, or the bound `update_with_attempts` takes) the outcome says `LostRace` instead of failing, and +returning `None` writes nothing and says `Declined`. `put` is the blind opt-out: last writer wins. + ## Python first call A document is a definition with an encoder and decoder; `name=` registers the name lookup, `indexes=` unique lookups and diff --git a/packages/cultcache-rs/CHANGELOG.md b/packages/cultcache-rs/CHANGELOG.md index 327f42aa..0cbf6708 100644 --- a/packages/cultcache-rs/CHANGELOG.md +++ b/packages/cultcache-rs/CHANGELOG.md @@ -6,8 +6,14 @@ only in git history. ## Unreleased +### Added + +- `CultCache::update_with_attempts` and the conditional `update`: each attempt reads the record from its home store with its content version in one read, applies a function to a copy and commits only over that version, rerunning on a lost race (default five attempts). The result is `CultUpdateResult`, outcome `Updated`, `Declined` (the function returned `None`) or `LostRace`, with the cache's document. Public types `CultStoredRecord`, `CultCommitIf`, `CultUpdateOutcome`, `CultUpdateResult`. +- `CacheBackingStore::read(key)` and `commit_if(entry, expected)`, provided methods whose defaults refuse with an error naming the store kind; `SingleFileMessagePackBackingStore` implements both. The content version is the lowercase hex SHA-256 of the record re-encoded as the store file writes it (key, schema id, `stored_at`, payload). It equals the SHA-256 of the record's bytes in the file, and so the C# reference's `tests/vectors/content-version`, whenever the file's MessagePack encodings are minimal, which every runtime's writer produces; a hand-edited file with a non-minimal encoding (for example a `str8` key that fits a fixstr) hashes differently from its slice here. New dependency `sha2` 0.10 and eight new lockfile crates: `sha2`, `digest`, `block-buffer`, `crypto-common`, `generic-array`, `typenum`, `cpufeatures`, `version_check`. + ### Changed +- The single-file store's `push`, `push_all` and `commit_if` stamp a replacing write strictly later than the record it replaces at the same key (a staged stamp stands only when it parses and is strictly later; otherwise the stamp is minted as the replaced stamp plus one microsecond, or now if later), with no identical-content exception, as C# does. A stamp with no successor (the replaced stamp is at year 9999's last microsecond) refuses the write and writes nothing, as C# `MintStoredAt` does. `commit_if` replaces only the record of the entry's own type under the key, so a retype is refused as `push` refuses it. The cache holds the stamp the store wrote. The redb stores and the store-level exact-envelope writes (`compare_and_swap_entry` and its family) still write the caller's envelope unchanged. - The single-file store re-checks for a link once the store lock is held (every write and the try-exchange), so a link put at the path while a writer waited on the lock is refused with `StoreLinkedError` and the lock released, as C# does, instead of being replaced. Pinned by a test that puts the link while a second holder owns the lock. - `CultCache::is_writable` does not look at store paths: a link at a store path is not predicted, and the write refuses it with `StoreLinkedError`. Pinned by a test through a cache attached to a linked store. @@ -15,6 +21,8 @@ only in git history. Semver: this is a breaking change in the 0.x line (0.3.x to 0.4.0). +- `CultCache::update` is replaced, not extended: `update::(key, apply)` takes `FnMut(Option) -> Option` and returns `Result>`. The blind `FnOnce(Option) -> T` form that returned `T` is gone; use `put` for a blind write. It reads the home store, not the cache's image, so a cache whose store has no `read` and `commit_if` (the redb stores, a custom store) refuses it before `apply` runs. +- `CacheBackingStore::push` returns `Result`, the `stored_at` written, and `push_all` returns `Result>`, one stamp per entry given in order (a soft write that wrote nothing returns an empty vec). A custom store must return what it wrote; the redb stores return the entries' own stamps. Huginn implements `CacheBackingStore` in five test stores under a pinned git rev: they change at its next bump. - A write at a store path that is a symbolic link, live or dangling, is refused with the new public `StoreLinkedError` (`error.downcast_ref::()`) before the lock and the replace, and writes nothing: `push`, `push_all`, `delete`, the exchanges, and a soft `push_all`, which used to skip a link and now refuses. Before, a write at a live link replaced the link with a regular file and a dangling link failed with `io::ErrorKind::NotFound`. The redb stores (`RedbMessagePackBackingStore`, `OwnedRedbMessagePackBackingStore`) are exempt: they are ruled migrated and deleted (`paged-store-redb-migrate-and-delete`) and still open a live link's target. Configure the link's target as the store path. Reads through a live link work. - A record whose payload carries unread slots is read-only. A payload with a slot past its type's declared slots, at any depth (record, nested record, list element, union arm, unit arm), still decodes, and every write or removal of its key is now refused before any store is touched: `put`, `put_envelope`, `put_raw_envelope`, `put_prepared_batch` (the whole batch) and `delete`, so `update` too. The refusal is `SchemaConflictError` with the new `unread_slots`; its message names the type and both slot counts and never a slot value. Holders of such a record used to overwrite it and drop the extra slots silently. The payload nesting refusal still runs first. A held payload this build cannot read at all (an arm it lacks from a newer writer, or a corrupt record) is read-only too: every write or removal is refused with an error naming the type and key (a newer writer's record is read with that build; a corrupt one is restored from a backup or removed at the store itself, as no cache call removes it), and `is_writable` answers false. `load_soa` is a write of every held key it omits or changes, so it refuses to drop or replace a read-only record. `is_writable` also answers false for a key a store carries as a foreign record and for a type other than the one holding the key, as the writes do. Each write over a held key decodes the held payload once to ask (about 40% of a `put` of a 200,000-element record, measured in release); keys nothing holds pay nothing. - cultmesh-rs `pull_rudp_catalog_snapshot` applies a snapshot as one cache commit (`put_prepared_batch`), as C# does: a snapshot document for a key the node holds read-only, or any other refusal, applies nothing. It used to apply document by document, so a refusal ended the pull with the earlier documents kept. A snapshot whose types route to different backing stores is now refused whole, as a batch cannot span stores. C# `CultCache.Commit` refuses a cross-store commit the same way (`The batch spans A and B; a commit lands in one home store`), so the two agree; a test pins it. diff --git a/packages/cultcache-rs/Cargo.lock b/packages/cultcache-rs/Cargo.lock index 40d9e1e8..6fad548a 100644 --- a/packages/cultcache-rs/Cargo.lock +++ b/packages/cultcache-rs/Cargo.lock @@ -38,6 +38,15 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -80,6 +89,25 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "cultcache-rs" version = "0.3.1" @@ -95,6 +123,7 @@ dependencies = [ "serde_bytes", "serde_json", "serde_stacker", + "sha2", "stacker", "tempfile", "uuid", @@ -116,6 +145,16 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "errno" version = "0.3.15" @@ -172,6 +211,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -433,6 +482,17 @@ dependencies = [ "stacker", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "2.0.1" @@ -493,6 +553,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.27" @@ -510,6 +576,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasm-bindgen" version = "0.2.130" diff --git a/packages/cultcache-rs/Cargo.toml b/packages/cultcache-rs/Cargo.toml index db8a46ac..1ed1ba3f 100644 --- a/packages/cultcache-rs/Cargo.toml +++ b/packages/cultcache-rs/Cargo.toml @@ -23,6 +23,7 @@ serde = { version = "1", features = ["derive"] } serde_bytes = "0.11" serde_json = "1" serde_stacker = "0.1.14" +sha2 = "0.10" stacker = "0.1.25" uuid = { version = "1", features = ["v4"] } diff --git a/packages/cultcache-rs/README.md b/packages/cultcache-rs/README.md index 06c530b1..d52fdea2 100644 --- a/packages/cultcache-rs/README.md +++ b/packages/cultcache-rs/README.md @@ -186,7 +186,7 @@ without pretending Rust has runtime reflection hiding under the floorboards. - `soa::` - `put::` - `put_envelope::` -- `update::` +- `update::` and `update_with_attempts::`: the conditional golden-path write (read the stored record, apply, commit only over what was read, rerun on a lost race) - `delete::` - `snapshot` - `snapshot_soa` @@ -304,6 +304,14 @@ The contract, shared with every runtime, is one home store per document type ## Persistence Semantics +`update` is the golden path for changing a record another writer may also change: each attempt reads the record and its content +version from the home store, hands `apply` a copy, and commits only if the store still holds that version. `put` is the blind +write. The single-file store stamps a replacing write (`push`, `push_all` and `update`'s commit) strictly later than the record +it replaces, and the cache holds the stamp the store reports; the redb stores and the exact-envelope family +(`compare_and_swap_entry` and the other store-level conditional writes) write the caller's envelope byte for byte. A store +needs `read` and `commit_if` to serve `update`; the redb stores do not have them. +The version is the SHA-256 of the record re-encoded as the store file writes it; that equals the hash of the record's bytes in the file (and the C# reference's vector) whenever the file's MessagePack encodings are minimal, as every runtime's writer produces. + `put` persists before mutating the in-memory cache. If persistence fails, the cache does not pretend the write succeeded. diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index 8fccdf97..cd18aea8 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -1290,6 +1290,39 @@ pub struct PushAllOptions { pub soft: bool, } +/// A record read from a store with its content version, both from one read. The version is the lowercase hex SHA-256 of the +/// record as the store file encodes it (key, schema id, `stored_at`, payload), so a rewrite of the same bytes under a later +/// stamp is a different version. +#[derive(Clone, Debug, PartialEq)] +pub struct CultStoredRecord { + pub envelope: CultCacheEnvelope, + pub version: String, +} + +/// The verdict of [`CacheBackingStore::commit_if`]: the write landed and carries this stamp, or the record at the key was not at +/// the expected version and nothing was written. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CultCommitIf { + Committed { stored_at: String }, + Mismatch, +} + +/// How [`CultCache::update`] ended. `Declined` wrote nothing because `apply` returned `None`; `LostRace` wrote nothing because +/// every attempt found the record changed under it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CultUpdateOutcome { + Updated, + Declined, + LostRace, +} + +/// The outcome of an update and the cache's document at the key after it (a read that decides nothing). +#[derive(Clone, Debug, PartialEq)] +pub struct CultUpdateResult { + pub outcome: CultUpdateOutcome, + pub document: Option, +} + /// One per-entry expectation for [`SingleFileMessagePackBackingStore::compare_exchange`]. /// `current` is the envelope the caller believes is stored, or `None` when it /// believes the identity is absent. @@ -1312,7 +1345,9 @@ pub enum TryCompareExchangeSnapshotOutcome { pub trait CacheBackingStore: Send { fn pull_all(&self) -> Result>; - fn push(&mut self, entry: &CultCacheEnvelope) -> Result<()>; + /// Writes `entry` and returns the `stored_at` the store wrote: a store that restamps a replacing write reports the stamp it + /// minted, and the cache holds that one. + fn push(&mut self, entry: &CultCacheEnvelope) -> Result; fn delete(&mut self, entry: &CultCacheEnvelope) -> Result<()>; /// Writes `entries` onto the store as it is when the write lands, in one @@ -1322,7 +1357,9 @@ pub trait CacheBackingStore: Send { /// it held before. A cache batch (`put_prepared_batch`) is all-or-nothing /// only through this. There is no default, because a write assembled from /// `push` calls leaves the store partial when one of them fails; a store - /// without an atomic write does not compile: + /// without an atomic write does not compile. It returns one stamp per entry given, in order, the `stored_at` written for it + /// (an entry deduplicated away gets the stamp of the one that landed for its schema id and key); a soft write that wrote + /// nothing returns none: /// /// ```compile_fail,E0046 /// use cultcache_rs::{CacheBackingStore, CultCacheEnvelope}; @@ -1333,15 +1370,33 @@ pub trait CacheBackingStore: Send { /// fn pull_all(&self) -> anyhow::Result> { /// Ok(Vec::new()) /// } - /// fn push(&mut self, _entry: &CultCacheEnvelope) -> anyhow::Result<()> { - /// Ok(()) + /// fn push(&mut self, _entry: &CultCacheEnvelope) -> anyhow::Result { + /// Ok(String::new()) /// } /// fn delete(&mut self, _entry: &CultCacheEnvelope) -> anyhow::Result<()> { /// Ok(()) /// } /// } /// ``` - fn push_all(&mut self, entries: &[CultCacheEnvelope], options: PushAllOptions) -> Result<()>; + fn push_all(&mut self, entries: &[CultCacheEnvelope], options: PushAllOptions) -> Result>; + + /// The record at `key`, of any type, with its content version, from one read; `None` when the store holds nothing there. + /// [`CultCache::update`] needs this and [`CacheBackingStore::commit_if`]; a store without them refuses it. + fn read(&self, _key: &str) -> Result> { + Err(anyhow!( + "{} has no read and commit_if: CultCache::update needs read and commit_if", + std::any::type_name::() + )) + } + + /// Writes `entry` over the record at its key only if that record's version is `expected` (`None`: nothing is there), in + /// one step under the store's own exclusion. A mismatch writes nothing. + fn commit_if(&mut self, _entry: &CultCacheEnvelope, _expected: Option<&str>) -> Result { + Err(anyhow!( + "{} has no read and commit_if: CultCache::update needs read and commit_if", + std::any::type_name::() + )) + } } #[derive(Clone, Debug, PartialEq, Eq)] @@ -2026,14 +2081,46 @@ impl CacheBackingStore for SingleFileMessagePackBackingStore { self.with_shared_lock(|| self.read_all_unlocked()) } - fn push(&mut self, entry: &CultCacheEnvelope) -> Result<()> { + fn push(&mut self, entry: &CultCacheEnvelope) -> Result { self.with_exclusive_lock(|| { let mut entries = self.read_all_unlocked()?; + let entry = restamped_over(entry, &entries)?; // A write replaces the record of its own type under its key; a second type under the key reaches the commit guard. - entries.retain(|candidate| entry_id(candidate) != entry_id(entry)); + entries.retain(|candidate| entry_id(candidate) != entry_id(&entry)); entries.push(entry.clone()); entries.sort_by_key(entry_id); - self.write_all_unlocked(&entries) + self.write_all_unlocked(&entries)?; + Ok(entry.stored_at) + }) + } + + fn read(&self, key: &str) -> Result> { + if is_absent(&self.path)? && is_absent(&self.lock_path())? { + return Ok(None); + } + self.with_shared_lock(|| { + Ok(self + .read_all_unlocked()? + .into_iter() + .find(|entry| entry.key == key) + .map(|envelope| CultStoredRecord { version: record_version(&envelope), envelope })) + }) + } + + fn commit_if(&mut self, entry: &CultCacheEnvelope, expected: Option<&str>) -> Result { + self.with_exclusive_lock(|| { + let mut entries = self.read_all_unlocked()?; + let actual = entries.iter().find(|held| held.key == entry.key).map(record_version); + if actual.as_deref() != expected { + return Ok(CultCommitIf::Mismatch); + } + let entry = restamped_over(entry, &entries)?; + // Only the record of the entry's own identity is replaced; a second type under the key reaches the commit guard, as push does. + entries.retain(|held| entry_id(held) != entry_id(&entry)); + entries.push(entry.clone()); + entries.sort_by_key(entry_id); + self.write_all_unlocked(&entries)?; + Ok(CultCommitIf::Committed { stored_at: entry.stored_at }) }) } @@ -2045,27 +2132,32 @@ impl CacheBackingStore for SingleFileMessagePackBackingStore { }) } - fn push_all(&mut self, entries: &[CultCacheEnvelope], options: PushAllOptions) -> Result<()> { + fn push_all(&mut self, entries: &[CultCacheEnvelope], options: PushAllOptions) -> Result> { self.with_exclusive_lock(|| { // A soft flush asks the one reader: nothing at the path is written, a store it reads is left alone, and anything // the reader refuses (an unreadable file, a dangling link) is refused here as it is for open. if options.soft && !self.read_store_unlocked()?.absent { - return Ok(()); + return Ok(Vec::new()); } // Of entries sharing a key and a schema id the last one given is the record, whatever their type names (a type // renamed under a stable id); it replaces the held record under that key and schema id. A second schema id // under a key reaches the commit guard, which judges the merged store. - let mut by_id = BTreeMap::<(String, String), &CultCacheEnvelope>::new(); - for entry in entries { - by_id.insert((schema_identity(entry).to_string(), entry.key.clone()), entry); - } let mut merged = self.read_all_unlocked()?; - let replaced: BTreeSet<_> = by_id.values().map(|entry| entry_id(entry)).collect(); + let mut by_id = BTreeMap::<(String, String), CultCacheEnvelope>::new(); + for entry in entries { + by_id.insert((schema_identity(entry).to_string(), entry.key.clone()), restamped_over(entry, &merged)?); + } + let replaced: BTreeSet<_> = by_id.values().map(entry_id).collect(); merged.retain(|held| !replaced.contains(&entry_id(held))); - merged.extend(by_id.into_values().cloned()); + let stamps = entries + .iter() + .map(|entry| by_id[&(schema_identity(entry).to_string(), entry.key.clone())].stored_at.clone()) + .collect(); + merged.extend(by_id.into_values()); merged.sort_by_key(entry_id); - self.write_all_unlocked(&merged) + self.write_all_unlocked(&merged)?; + Ok(stamps) }) } } @@ -2827,14 +2919,14 @@ impl CacheBackingStore for OwnedRedbMessagePackBackingStore { read_all_redb(&table) } - fn push(&mut self, entry: &CultCacheEnvelope) -> Result<()> { + fn push(&mut self, entry: &CultCacheEnvelope) -> Result { let write = self.inner.database.begin_write()?; { let mut table = write.open_table(REDB_ENVELOPES)?; insert_redb_entry(&mut table, entry)?; } write.commit()?; - Ok(()) + Ok(entry.stored_at.clone()) } fn delete(&mut self, entry: &CultCacheEnvelope) -> Result<()> { @@ -2848,7 +2940,7 @@ impl CacheBackingStore for OwnedRedbMessagePackBackingStore { Ok(()) } - fn push_all(&mut self, entries: &[CultCacheEnvelope], _options: PushAllOptions) -> Result<()> { + fn push_all(&mut self, entries: &[CultCacheEnvelope], _options: PushAllOptions) -> Result> { unique_batch_ids(entries, "push all")?; let write = self.inner.database.begin_write()?; { @@ -2858,7 +2950,7 @@ impl CacheBackingStore for OwnedRedbMessagePackBackingStore { } } write.commit()?; - Ok(()) + Ok(entries.iter().map(|entry| entry.stored_at.clone()).collect()) } } @@ -2957,7 +3049,7 @@ impl CacheBackingStore for RedbMessagePackBackingStore { }) } - fn push(&mut self, entry: &CultCacheEnvelope) -> Result<()> { + fn push(&mut self, entry: &CultCacheEnvelope) -> Result { self.with_database(|database| { let write = database.begin_write()?; { @@ -2965,7 +3057,7 @@ impl CacheBackingStore for RedbMessagePackBackingStore { insert_redb_entry(&mut table, entry)?; } write.commit()?; - Ok(()) + Ok(entry.stored_at.clone()) }) } @@ -2982,7 +3074,7 @@ impl CacheBackingStore for RedbMessagePackBackingStore { }) } - fn push_all(&mut self, entries: &[CultCacheEnvelope], _options: PushAllOptions) -> Result<()> { + fn push_all(&mut self, entries: &[CultCacheEnvelope], _options: PushAllOptions) -> Result> { unique_batch_ids(entries, "push all")?; self.with_database(|database| { let write = database.begin_write()?; @@ -2993,7 +3085,7 @@ impl CacheBackingStore for RedbMessagePackBackingStore { } } write.commit()?; - Ok(()) + Ok(entries.iter().map(|entry| entry.stored_at.clone()).collect()) }) } } @@ -3413,19 +3505,40 @@ impl CultCache { /// carries past this build's, or because this build cannot read the payload at all. fn refuse_unread_slots(&self, type_id: &str, key: &str) -> Result<()> { match self.held_finding(type_id, key)? { + HeldFinding::Free => Ok(()), + finding => { + let held = &self.entries[&entry_id_parts(type_id, key)]; + self.refuse_finding(type_id, key, schema_identity(held), finding) + } + } + } + + /// The same refusal for a record read from a store: [`CultCache::update`] copies it, so it must not carry slots this build + /// would drop or bytes it cannot read. + fn refuse_record_slots(&self, type_id: &str, key: &str, schema_id: &str, payload: &[u8]) -> Result<()> { + let Some(definition) = self.definitions.get(type_id) else { + bail!("No entry type registered for CultCache envelope type {type_id:?}"); + }; + let finding = match (definition.unread_slots)(payload) { + Ok(None) => return Ok(()), + Ok(Some(unread)) => HeldFinding::Marked(unread), + Err(error) => HeldFinding::Unreadable(error), + }; + self.refuse_finding(type_id, key, schema_id, finding) + } + + fn refuse_finding(&self, type_id: &str, key: &str, schema_id: &str, finding: HeldFinding) -> Result<()> { + match finding { HeldFinding::Free => Ok(()), HeldFinding::Unreadable(error) => Err(error.context(format!( "CultCache entry {type_id:?} at key {key:?} is held read-only: this build cannot read its payload. A newer writer's record is read with that build; a corrupt one is restored from a backup or removed at the store itself, not through the cache" ))), - HeldFinding::Marked(unread) => { - let held = &self.entries[&entry_id_parts(type_id, key)]; - Err(anyhow::Error::new(SchemaConflictError { - schema_id: schema_identity(held).to_string(), - schema_names: vec![self.definitions[type_id].schema_name.to_string()], - record_key: key.to_string(), - unread_slots: Some(unread), - })) - } + HeldFinding::Marked(unread) => Err(anyhow::Error::new(SchemaConflictError { + schema_id: schema_id.to_string(), + schema_names: vec![self.definitions[type_id].schema_name.to_string()], + record_key: key.to_string(), + unread_slots: Some(unread), + })), } } @@ -3561,14 +3674,7 @@ impl CultCache { self.require_loaded()?; let (entry, parsed) = self.prepare_entry(key, value)?; self.refuse_unread_slots(&entry.r#type, &entry.key)?; - self.refuse_second_global(&self.entries, &entry.r#type, &entry.key)?; - self.refuse_retype(&self.entries, &self.foreign, &entry)?; - let change = self.judge_unique(&[&entry])?; - if let Some(home) = self.home_index(T::TYPE)? { - self.stores[home].store.push(&entry)?; - } - self.entries.insert(entry_id(&entry), entry); - change.apply(&mut self.index_holders); + self.write_entry(entry, None)?; Ok(parsed) } @@ -3687,10 +3793,22 @@ impl CultCache { if let Some(store_index) = store_index { // The home store is handed the staged records alone and applies them onto what it holds now. // A key staged twice lands as its last record, whatever the store. - let landing: BTreeMap<_, _> = staged.iter().map(|entry| (entry_id(entry), entry.clone())).collect(); - self.stores[store_index] - .store - .push_all(&landing.into_values().collect::>(), PushAllOptions::default())?; + let landing: Vec = staged + .iter() + .map(|entry| (entry_id(entry), entry.clone())) + .collect::>() + .into_values() + .collect(); + let stamps = self.stores[store_index].store.push_all(&landing, PushAllOptions::default())?; + if stamps.len() == landing.len() { + for (entry, stamp) in landing.iter().zip(stamps) { + if chrono::DateTime::parse_from_rfc3339(&stamp).is_ok() + && let Some(held) = next.get_mut(&entry_id(entry)) + { + held.stored_at = stamp; + } + } + } } self.entries = next; change.apply(&mut self.index_holders); @@ -3702,14 +3820,7 @@ impl CultCache { let parsed = self.validate_envelope::(&entry)?; self.refuse_unread_slots(&entry.r#type, &entry.key)?; self.refuse_claimed_id(&entry)?; - self.refuse_second_global(&self.entries, &entry.r#type, &entry.key)?; - self.refuse_retype(&self.entries, &self.foreign, &entry)?; - let change = self.judge_unique(&[&entry])?; - if let Some(home) = self.home_index(T::TYPE)? { - self.stores[home].store.push(&entry)?; - } - self.entries.insert(entry_id(&entry), entry); - change.apply(&mut self.index_holders); + self.write_entry(entry, None)?; Ok(parsed) } @@ -3741,15 +3852,33 @@ impl CultCache { require_record_payload(&entry)?; self.refuse_unread_slots(&entry.r#type, &entry.key)?; self.refuse_claimed_id(&entry)?; + self.write_entry(entry, None)?; + Ok(()) + } + + /// The one landing of a single record, for `put`, `put_envelope`, `put_raw_envelope` and `update`: the global, retype and + /// unique judges, then the home store's write, then the install of the stamp the store reports (kept only when it parses + /// as RFC 3339, else the entry's own). `expected` is `None` for a blind write; `Some(version)` writes only over the record at + /// that version (`Some(None)`: over nothing) and returns `false`, installing nothing, when the store found another. + fn write_entry(&mut self, mut entry: CultCacheEnvelope, expected: Option>) -> Result { self.refuse_second_global(&self.entries, &entry.r#type, &entry.key)?; self.refuse_retype(&self.entries, &self.foreign, &entry)?; let change = self.judge_unique(&[&entry])?; if let Some(home) = self.home_index(&entry.r#type)? { - self.stores[home].store.push(&entry)?; + let stored_at = match expected { + None => self.stores[home].store.push(&entry)?, + Some(version) => match self.stores[home].store.commit_if(&entry, version.as_deref())? { + CultCommitIf::Committed { stored_at } => stored_at, + CultCommitIf::Mismatch => return Ok(false), + }, + }; + if chrono::DateTime::parse_from_rfc3339(&stored_at).is_ok() { + entry.stored_at = stored_at; + } } self.entries.insert(entry_id(&entry), entry); change.apply(&mut self.index_holders); - Ok(()) + Ok(true) } fn validate_envelope(&self, entry: &CultCacheEnvelope) -> Result { @@ -3784,13 +3913,82 @@ impl CultCache { }) } - pub fn update(&mut self, key: &str, updater: F) -> Result + /// Read the record at `key` as its home store holds it now, apply `apply` to a copy, and write the result only if the store + /// still holds the record that was read; on a lost race run `apply` again on a fresh read, up to 5 attempts + /// ([`CultCache::update_with_attempts`] takes the bound). `apply` may run several times and must have no side effects. + /// `None` from `apply` writes nothing ([`CultUpdateOutcome::Declined`]); losing every attempt is a value + /// ([`CultUpdateOutcome::LostRace`]), not an error. On `Updated` the write is durable. [`CultCache::put`] is the blind write. + pub fn update(&mut self, key: &str, apply: F) -> Result> where T: DatabaseEntry, - F: FnOnce(Option) -> T, + F: FnMut(Option) -> Option, { - let current = self.get::(key)?; - self.put::(key.to_string(), &updater(current)) + self.update_with_attempts(key, 5, apply) + } + + /// [`CultCache::update`] with its attempt bound: `max_attempts` of 0 is refused before `apply` runs. A refusal, a store + /// error or a failed judge is returned at once and never retried; after the last lost attempt the cache pulls its stores + /// (display only) and reports `LostRace`. + pub fn update_with_attempts(&mut self, key: &str, max_attempts: usize, mut apply: F) -> Result> + where + T: DatabaseEntry, + F: FnMut(Option) -> Option, + { + if max_attempts == 0 { + bail!("update max_attempts must be at least 1"); + } + for attempt in 1..=max_attempts { + let (copy, expected) = self.read_for_update::(key)?; + let Some(next) = apply(copy) else { + return Ok(CultUpdateResult { outcome: CultUpdateOutcome::Declined, document: self.get::(key)? }); + }; + let (entry, _) = self.prepare_entry(key, &next)?; + if self.write_entry(entry, expected)? { + return Ok(CultUpdateResult { outcome: CultUpdateOutcome::Updated, document: self.get::(key)? }); + } + if attempt == max_attempts { + self.pull_all_backing_stores()?; + return Ok(CultUpdateResult { outcome: CultUpdateOutcome::LostRace, document: self.get::(key)? }); + } + } + unreachable!("the last attempt returns") + } + + /// One attempt's read: the refusals that precede `apply`, then the record at `key` as the home store holds it with the version + /// to commit against (`Some(None)` for nothing there; `None` for a cache with no store, which has no version). Installs + /// nothing in the cache. + #[allow(clippy::type_complexity)] + fn read_for_update(&self, key: &str) -> Result<(Option, Option>)> { + self.require_loaded()?; + self.require_entry_type::()?; + self.refuse_unread_slots(T::TYPE, key)?; + let probe = CultCacheEnvelope { + key: key.to_string(), + r#type: T::TYPE.to_string(), + payload: Vec::new(), + stored_at: String::new(), + schema_id: None, + }; + self.refuse_retype(&self.entries, &self.foreign, &probe)?; + let Some(home) = self.home_index(T::TYPE)? else { + return Ok((self.get::(key)?, None)); + }; + let Some(found) = self.stores[home].store.read(key)? else { + return Ok((None, Some(None))); + }; + let known_types: BTreeSet = self.definitions.keys().cloned().collect(); + let resolved = resolve_registered_type( + &known_types, + &self.schema_name_definitions, + found.envelope.schema_id.as_deref(), + &found.envelope.r#type, + ); + if resolved.as_deref() != Some(T::TYPE) { + bail!("Record {key} is not a {}; update cannot copy it.", T::TYPE); + } + self.refuse_record_slots(T::TYPE, key, schema_identity(&found.envelope), &found.envelope.payload)?; + let copy = decode_payload::(&found.envelope.payload)?; + Ok((Some(copy), Some(Some(found.version)))) } pub fn delete(&mut self, key: &str) -> Result { @@ -4076,6 +4274,60 @@ fn entry_id_parts(r#type: &str, key: &str) -> (String, String) { (r#type.to_string(), key.to_string()) } +/// The content version of a stored record: lowercase hex SHA-256 of the record exactly as the store file encodes it, so it +/// covers the key, schema id, `stored_at` and payload. The only hash in this crate. +fn record_version(entry: &CultCacheEnvelope) -> String { + use sha2::{Digest, Sha256}; + let record = PersistedRecord( + entry.key.clone(), + schema_identity(entry).to_string(), + entry.stored_at.clone(), + entry.payload.clone(), + ); + let bytes = rmp_serde::to_vec(&record).expect("a record of strings and bytes encodes"); + Sha256::digest(bytes).iter().map(|byte| format!("{byte:02x}")).collect() +} + +/// A stamp strictly later than `previous` (to the microsecond): `now` truncated to the microsecond when that is later, +/// otherwise `previous` truncated plus one microsecond. An absent or unparseable `previous` gives `now`. A successor past +/// year 9999 does not exist as a stamp any runtime reads, so it is refused (C# `MintStoredAt` throws there). +fn mint_stored_at(previous: Option<&str>, now: chrono::DateTime) -> Result { + use chrono::SubsecRound; + let now = now.trunc_subsecs(6); + let minted = match previous.and_then(|text| chrono::DateTime::parse_from_rfc3339(text).ok()) { + Some(previous) => { + let previous = previous.with_timezone(&chrono::Utc).trunc_subsecs(6); + if now <= previous { + previous + .checked_add_signed(chrono::Duration::microseconds(1)) + .filter(|successor| chrono::Datelike::year(successor) <= 9999) + .ok_or_else(|| anyhow!("stored_at {previous} has no later stamp: the successor is past year 9999"))? + } else { + now + } + } + None => now, + }; + Ok(minted.to_rfc3339_opts(chrono::SecondsFormat::Micros, true)) +} + +/// The stamp a replacing write carries: `staged` stands only when both stamps parse and it is strictly later than `replaced`. +fn stored_at_over(staged: &str, replaced: &str) -> Result { + match (chrono::DateTime::parse_from_rfc3339(staged), chrono::DateTime::parse_from_rfc3339(replaced)) { + (Ok(staged_at), Ok(replaced_at)) if staged_at > replaced_at => Ok(staged.to_string()), + _ => mint_stored_at(Some(replaced), chrono::Utc::now()), + } +} + +/// `entry` stamped later than the record the store holds at its key, of any type; unchanged where nothing is held. +fn restamped_over(entry: &CultCacheEnvelope, held: &[CultCacheEnvelope]) -> Result { + let mut entry = entry.clone(); + if let Some(replaced) = held.iter().find(|candidate| candidate.key == entry.key) { + entry.stored_at = stored_at_over(&entry.stored_at, &replaced.stored_at)?; + } + Ok(entry) +} + fn now_utc_second() -> String { chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true) } @@ -4924,7 +5176,7 @@ mod tests { fn pull_all(&self) -> Result> { self.inner.pull_all() } - fn push(&mut self, entry: &CultCacheEnvelope) -> Result<()> { + fn push(&mut self, entry: &CultCacheEnvelope) -> Result { self.inner.push(entry) } fn delete(&mut self, entry: &CultCacheEnvelope) -> Result<()> { @@ -4934,7 +5186,7 @@ mod tests { &mut self, entries: &[CultCacheEnvelope], options: PushAllOptions, - ) -> Result<()> { + ) -> Result> { if self.refuse_batch.load(Ordering::SeqCst) { return Err(anyhow!("injected batch refusal")); } @@ -5554,8 +5806,8 @@ mod tests { let absent_note = CultCacheExpectedEnvelope { key: "k".to_string(), r#type: Note::TYPE.to_string(), current: None }; type Writer = fn(&mut SingleFileMessagePackBackingStore, &[CultCacheEnvelope; 3], &CultCacheExpectedEnvelope) -> Result; let writers: Vec<(&str, Writer)> = vec![ - ("push", |s, e, _| s.push(&e[2]).map(|()| true)), - ("push_all", |s, e, _| s.push_all(&[e[0].clone(), e[2].clone()], PushAllOptions::default()).map(|()| true)), + ("push", |s, e, _| s.push(&e[2]).map(|_| true)), + ("push_all", |s, e, _| s.push_all(&[e[0].clone(), e[2].clone()], PushAllOptions::default()).map(|_| true)), ("insert_entry_if_absent", |s, e, _| s.insert_entry_if_absent(e[2].clone())), ("compare_and_swap_entry", |s, e, _| s.compare_and_swap_entry(&e[0], e[2].clone())), ("compare_exchange", |s, e, x| s.compare_exchange(std::slice::from_ref(x), &[e[2].clone()])), @@ -5715,6 +5967,52 @@ mod tests { Ok(()) } + fn is_minted_text(text: &str) -> bool { + let bytes = text.as_bytes(); + bytes.len() == 27 + && bytes.ends_with(b"Z") + && bytes[19] == b'.' + && bytes[20..26].iter().all(u8::is_ascii_digit) + && chrono::DateTime::parse_from_rfc3339(text).is_ok() + } + + fn at(text: &str) -> chrono::DateTime { + chrono::DateTime::parse_from_rfc3339(text).unwrap() + } + + #[test] + fn stamps_stand_only_when_strictly_later_and_otherwise_mint_over_the_replaced() { + // A strictly later staged stamp stands as written. + assert_eq!(stored_at_over("2099-01-01T00:00:00Z", "2026-01-01T00:00:00Z").unwrap(), "2099-01-01T00:00:00Z"); + // An equal or earlier one is replaced by a minted stamp later than the replaced, over C#'s seven digits too. + for replaced in ["2026-01-01T00:00:00.1234567+00:00", "2026-01-01T00:00:00Z", "2099-01-01T00:00:00Z"] { + for staged in [replaced, "2020-01-01T00:00:00Z"] { + let minted = stored_at_over(staged, replaced).unwrap(); + assert!(is_minted_text(&minted), "{minted}"); + assert!(at(&minted) > at(replaced), "{minted} over {replaced}"); + } + } + assert_eq!(stored_at_over("2020-01-01T00:00:00Z", "2099-01-01T00:00:00Z").unwrap(), "2099-01-01T00:00:00.000001Z"); + // An unparseable stamp on either side is never kept: the minted stamp is the replaced's successor or now. + let minted = stored_at_over("not a stamp", "2099-01-01T00:00:00Z").unwrap(); + assert_eq!(minted, "2099-01-01T00:00:00.000001Z"); + let minted = stored_at_over("2099-01-01T00:00:00Z", "not a stamp").unwrap(); + assert!(is_minted_text(&minted)); + assert!((chrono::Utc::now() - at(&minted).with_timezone(&chrono::Utc)).num_seconds().abs() < 60, "{minted}"); + assert!(is_minted_text(&mint_stored_at(None, chrono::Utc::now()).unwrap())); + } + + #[test] + fn a_stamp_with_no_successor_in_year_9999_is_refused() { + for replaced in ["9999-12-31T23:59:59.9999999+00:00", "9999-12-31T23:59:59.999999Z"] { + for staged in [replaced, "2020-01-01T00:00:00Z"] { + assert!(stored_at_over(staged, replaced).is_err(), "{staged} over {replaced}"); + } + } + // One microsecond earlier still has a successor. + assert_eq!(stored_at_over("2020-01-01T00:00:00Z", "9999-12-31T23:59:59.999998Z").unwrap(), "9999-12-31T23:59:59.999999Z"); + } + #[test] fn failed_snapshot_publication_preserves_the_committed_destination() -> Result<()> { let temp = tempfile::tempdir()?; @@ -6633,9 +6931,10 @@ mod tests { retries: 0, }); current.retries += 1; - current + Some(current) })?; - assert_eq!(updated.retries, 1); + assert_eq!(updated.outcome, CultUpdateOutcome::Updated); + assert_eq!(updated.document.map(|settings| settings.retries), Some(1)); assert!(cache.delete::("app")?); assert!(cache.get::("app")?.is_none()); Ok(()) @@ -7351,10 +7650,10 @@ mod tests { let mut store = SingleFileMessagePackBackingStore::new(&path); let outcome = match operation { "open" => store.pull_all().map(|_| ()), - "soft_push_all" => store.push_all(&[snapshot_envelope("x", b"one")], PushAllOptions { soft: true }), - "push_all" => store.push_all(&[snapshot_envelope("x", b"one")], PushAllOptions::default()), + "soft_push_all" => store.push_all(&[snapshot_envelope("x", b"one")], PushAllOptions { soft: true }).map(|_| ()), + "push_all" => store.push_all(&[snapshot_envelope("x", b"one")], PushAllOptions::default()).map(|_| ()), "delete" => store.delete(&snapshot_envelope("x", b"one")), - _ => store.push(&snapshot_envelope("x", b"one")), + _ => store.push(&snapshot_envelope("x", b"one")).map(|_| ()), }; let what = format!("{vector} {operation}"); assert_eq!(outcome.is_ok(), reads, "{what}: {outcome:?}"); @@ -8016,7 +8315,7 @@ mod tests { let mut store = SingleFileMessagePackBackingStore::new(&path); let outcome = match operation { "open" => store.pull_all().map(|_| ()), - _ => store.push_all(&[snapshot_envelope("x", b"one")], PushAllOptions::default()), + _ => store.push_all(&[snapshot_envelope("x", b"one")], PushAllOptions::default()).map(|_| ()), }; let error = outcome.expect_err(&format!("{vector} {operation}")); assert!(unreadable_kind(&error).is_some(), "{vector} {operation}: {error:#}"); @@ -8168,7 +8467,7 @@ mod tests { let mut writer = SingleFileMessagePackBackingStore::new(&path); let delta = snapshot_envelope("delta", b"4"); let writes = [ - writer.push(&delta), + writer.push(&delta).map(|_| ()), writer.delete(&delta), writer.insert_entry_if_absent(delta.clone()).map(|_| ()), writer.compare_and_swap_batch(&[], vec![delta.clone()]).map(|_| ()), @@ -8339,8 +8638,8 @@ mod tests { let dangling = volume.join("missing.cc"); let path = temp.path().join("late.cc"); let writers: [(&str, fn(&mut SingleFileMessagePackBackingStore) -> Result<()>); 4] = [ - ("push", |s| s.push(&snapshot_envelope("b", b"2"))), - ("push_all", |s| s.push_all(&[snapshot_envelope("b", b"2")], PushAllOptions::default())), + ("push", |s| s.push(&snapshot_envelope("b", b"2")).map(|_| ())), + ("push_all", |s| s.push_all(&[snapshot_envelope("b", b"2")], PushAllOptions::default()).map(|_| ())), ("delete", |s| s.delete(&snapshot_envelope("a", b"1"))), ("compare_exchange_snapshot", |s| s.compare_exchange_snapshot(&[], &[snapshot_envelope("b", b"2")]).map(|_| ())), ]; diff --git a/packages/cultcache-rs/tests/content_version_vector.rs b/packages/cultcache-rs/tests/content_version_vector.rs new file mode 100644 index 00000000..56304b6f --- /dev/null +++ b/packages/cultcache-rs/tests/content_version_vector.rs @@ -0,0 +1,26 @@ +//! `tests/vectors/content-version`, written only by the C# reference: the content version of a stored record is the lowercase +//! hex SHA-256 of its persisted encoding (`variants:ruling:write-set-content-version-declared-ownership`). The store reports +//! the version of every record of the committed `store.cc` as `versions.msgpack` says. + +use std::path::PathBuf; + +use cultcache_rs::{CacheBackingStore, SingleFileMessagePackBackingStore}; + +fn vectors() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../tests/vectors/content-version") +} + +#[test] +fn read_reports_every_version_the_reference_reads() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("store.cc"); + std::fs::copy(vectors().join("store.cc"), &path).unwrap(); + let rows: Vec<(String, String)> = rmp_serde::from_slice(&std::fs::read(vectors().join("versions.msgpack")).unwrap()).unwrap(); + assert!(rows.len() >= 2); + let store = SingleFileMessagePackBackingStore::new(&path); + for (key, version) in rows { + let record = store.read(&key).unwrap().unwrap_or_else(|| panic!("no record {key}")); + assert_eq!(record.version, version, "{key}"); + } + assert_eq!(store.read("absent").unwrap(), None); +} diff --git a/packages/cultcache-rs/tests/first_call.rs b/packages/cultcache-rs/tests/first_call.rs index 6859ae07..c6d8d7f5 100644 --- a/packages/cultcache-rs/tests/first_call.rs +++ b/packages/cultcache-rs/tests/first_call.rs @@ -1,7 +1,9 @@ -//! The Rust CultCache first call from docs/start/README.md. The docs show the region between the -//! `snippet` markers verbatim, and `docs_show_the_tested_snippet` fails when they differ. +//! The Rust CultCache first call and update from docs/start/README.md. The docs show each region between its +//! `snippet` markers verbatim, and `docs_show_the_tested_snippets` fails when they differ. -use cultcache_rs::{CultCache, DatabaseEntry, SingleFileMessagePackBackingStore}; +use std::path::Path; + +use cultcache_rs::{CultCache, CultUpdateResult, DatabaseEntry, SingleFileMessagePackBackingStore}; // snippet:begin #[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] @@ -40,17 +42,59 @@ fn first_call() -> anyhow::Result<()> { } // snippet:end +// snippet:update:begin +fn restock(path: &Path) -> anyhow::Result> { + let mut cache = CultCache::new(); + cache.register_entry_type::()?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(path))?; + cache.pull_all_backing_stores()?; + cache.update::("item:potion", |item| { + // The store's copy of the record, or nothing if it has none; this may run again on a lost race. + let mut item = item.unwrap_or(Item { + name: "Potion".to_string(), + category: "Consumable".to_string(), + value: 0, + }); + item.value += 10; + Some(item) + }) +} +// snippet:update:end + #[test] fn first_call_round_trips_through_a_second_cache() { first_call().unwrap(); } #[test] -fn docs_show_the_tested_snippet() { +fn restock_twice_reads_twenty_through_a_second_cache() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("restock.cc"); + assert_eq!(restock(&path).unwrap().outcome, cultcache_rs::CultUpdateOutcome::Updated); + let second = restock(&path).unwrap(); + assert_eq!(second.outcome, cultcache_rs::CultUpdateOutcome::Updated); + assert_eq!(second.document.unwrap().value, 20); + + let mut reopened = CultCache::new(); + reopened.register_entry_type::().unwrap(); + reopened.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap(); + reopened.pull_all_backing_stores().unwrap(); + assert_eq!(reopened.get_required::("item:potion").unwrap().value, 20); +} + +/// The docs show the region between `// snippet:begin` and `end` of this file as one fenced block. +fn docs_show_region(region: &str) { let source = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/first_call.rs")).unwrap(); - let begin = source.find("// snippet:begin\n").unwrap() + "// snippet:begin\n".len(); - let end = source.find("// snippet:end\n").unwrap(); + let (begin_marker, end_marker) = (format!("// snippet:{region}begin\n"), format!("// snippet:{region}end\n")); + let begin = source.find(&begin_marker).unwrap() + begin_marker.len(); + let end = source.find(&end_marker).unwrap(); let docs = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../docs/start/README.md")).unwrap(); let block = format!("```rust\n{}```", &source[begin..end]); - assert!(docs.contains(&block), "docs/start/README.md no longer shows tests/first_call.rs verbatim"); + assert!(docs.contains(&block), "docs/start/README.md no longer shows the {region:?} region of tests/first_call.rs verbatim"); +} + +#[test] +fn docs_show_the_tested_snippets() { + docs_show_region(""); + docs_show_region("update:"); } diff --git a/packages/cultcache-rs/tests/slot_readonly.rs b/packages/cultcache-rs/tests/slot_readonly.rs index cea0853a..7851c818 100644 --- a/packages/cultcache-rs/tests/slot_readonly.rs +++ b/packages/cultcache-rs/tests/slot_readonly.rs @@ -270,7 +270,7 @@ fn every_write_entry_refuses_a_marked_key_before_any_store_is_touched() { assert_refused_with(&cache.put_prepared_batch(vec![replacing]).unwrap_err(), "marked", &expected); assert_refused_with(&cache.delete::("marked").unwrap_err(), "marked", &expected); assert_refused_with( - &cache.update::("marked", |current| current.unwrap()).unwrap_err(), + &cache.update::("marked", |current| current).unwrap_err(), "marked", &expected, ); diff --git a/packages/cultcache-rs/tests/union_parity.rs b/packages/cultcache-rs/tests/union_parity.rs index 5dbe0e45..81a0da7a 100644 --- a/packages/cultcache-rs/tests/union_parity.rs +++ b/packages/cultcache-rs/tests/union_parity.rs @@ -674,7 +674,7 @@ fn a_refused_load_refuses_every_write() { ("put_prepared_batch", cache.put_prepared_batch(vec![staged.clone()]).unwrap_err()), ("put_envelope", cache.put_envelope::(staged.clone()).unwrap_err()), ("put_raw_envelope", cache.put_raw_envelope(staged.clone()).unwrap_err()), - ("update", cache.update::("good", |current| current.unwrap_or_else(constructed)).unwrap_err()), + ("update", cache.update::("good", |current| Some(current.unwrap_or_else(constructed))).unwrap_err()), ("delete", cache.delete::("good").unwrap_err()), ("delete a key the image lacks", cache.delete::("absent").unwrap_err()), ]; diff --git a/packages/cultcache-rs/tests/unique_index.rs b/packages/cultcache-rs/tests/unique_index.rs index 58b97293..1e3ea6a2 100644 --- a/packages/cultcache-rs/tests/unique_index.rs +++ b/packages/cultcache-rs/tests/unique_index.rs @@ -221,7 +221,7 @@ fn a_record_rewriting_its_own_key_keeps_its_value_and_a_change_releases_the_old_ assert_eq!(cache.get_by_index::("Sku", "S").unwrap().unwrap().sku, "S"); assert_eq!(cache.get_by_index::("Sku", "T").unwrap().unwrap().sku, "T"); // Updating through `update` is the same rule. - assert!(cache.update::("k", |_| unique("S")).is_err()); + assert!(cache.update::("k", |_| Some(unique("S"))).is_err()); assert_eq!(cache.get::("k").unwrap().unwrap().sku, "T"); } @@ -326,7 +326,7 @@ fn every_write_path_refuses_and_changes_nothing() { assert_eq!(text, refusal(first, second, "Multi", alias)); }; pick(&error_text(cache.put("m:new", clash))); - pick(&error_text(cache.update::("m:new", |_| clash.clone()))); + pick(&error_text(cache.update::("m:new", |_| Some(clash.clone())))); let envelope = staged(&cache, &[("m:new", clash.clone())]).remove(0); pick(&error_text(cache.put_envelope::(envelope.clone()))); pick(&error_text(cache.put_raw_envelope(envelope.clone()))); diff --git a/packages/cultcache-rs/tests/update.rs b/packages/cultcache-rs/tests/update.rs new file mode 100644 index 00000000..0cc19d95 --- /dev/null +++ b/packages/cultcache-rs/tests/update.rs @@ -0,0 +1,770 @@ +//! `CultCache::update`: read the record as its home store holds it, apply, commit only over what was read, rerun on a lost race +//! (C# `CultCache.Update`, as amended by PR #170). A rival is a second `CultCache` on the same file. + +use std::cell::Cell; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Barrier}; + +use cultcache_rs::{ + CacheBackingStore, CultCache, CultCacheEnvelope, CultCommitIf, CultStoredRecord, CultUpdateOutcome, DatabaseEntry, + PushAllOptions, RedbMessagePackBackingStore, SingleFileMessagePackBackingStore, +}; + +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "counter")] +struct Counter { + #[cultcache(key = 0)] + n: u32, +} + +/// The same type with a second slot: a record it writes carries a slot `Counter` does not read. +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "counter")] +struct WideCounter { + #[cultcache(key = 0)] + n: u32, + #[cultcache(key = 1)] + extra: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "note")] +struct Note { + #[cultcache(key = 0)] + text: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "unique_sku")] +struct Sku { + #[cultcache(key = 0, index = "Sku")] + sku: String, +} + +fn counter(n: u32) -> Counter { + Counter { n } +} + +fn open(path: &Path) -> CultCache { + let mut cache = CultCache::new(); + cache.register_entry_type::().unwrap(); + cache.register_entry_type::().unwrap(); + cache.register_entry_type::().unwrap(); + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(path)).unwrap(); + cache.pull_all_backing_stores().unwrap(); + cache +} + +fn path_in(dir: &tempfile::TempDir) -> PathBuf { + dir.path().join("store.cc") +} + +/// What a fresh cache reads at `key`. +fn stored(path: &Path, key: &str) -> Option { + open(path).get::(key).unwrap().map(|counter| counter.n) +} + +fn file(path: &Path) -> Option> { + std::fs::read(path).ok() +} + +#[test] +fn two_writers_updating_lose_no_increment() { + let dir = tempfile::tempdir().unwrap(); + let path = path_in(&dir); + let calls = Arc::new(AtomicUsize::new(0)); + let start = Arc::new(Barrier::new(2)); + let threads: Vec<_> = (0..2) + .map(|_| { + let (path, calls, start) = (path.clone(), calls.clone(), start.clone()); + std::thread::spawn(move || { + let mut cache = open(&path); + start.wait(); + for _ in 0..50 { + let result = cache + .update_with_attempts::("c", 100, |current| { + calls.fetch_add(1, Ordering::SeqCst); + // The window a rival can land in. + std::thread::sleep(std::time::Duration::from_millis(1)); + Some(counter(current.map_or(0, |held| held.n) + 1)) + }) + .unwrap(); + assert_eq!(result.outcome, CultUpdateOutcome::Updated); + } + }) + }) + .collect(); + for thread in threads { + thread.join().unwrap(); + } + assert_eq!(stored(&path, "c"), Some(100)); + assert!(calls.load(Ordering::SeqCst) > 100, "no apply was rerun, so no race was exercised"); +} + +#[test] +fn a_lost_attempt_reruns_apply_on_the_fresh_value() { + let dir = tempfile::tempdir().unwrap(); + let path = path_in(&dir); + let mut cache = open(&path); + cache.put("c", &counter(1)).unwrap(); + let mut rival = open(&path); + let mut seen = Vec::new(); + let result = cache + .update::("c", |current| { + let current = current.unwrap().n; + if seen.is_empty() { + rival.put("c", &counter(100)).unwrap(); + } + seen.push(current); + Some(counter(current + 1)) + }) + .unwrap(); + assert_eq!(seen, [1, 100]); + assert_eq!(result.outcome, CultUpdateOutcome::Updated); + assert_eq!(result.document, Some(counter(101))); + assert_eq!(stored(&path, "c"), Some(101)); +} + +#[test] +fn exhausted_attempts_return_lost_race() { + let dir = tempfile::tempdir().unwrap(); + let path = path_in(&dir); + let mut cache = open(&path); + cache.put("c", &counter(1)).unwrap(); + let mut rival = open(&path); + let mut calls = 0; + let result = cache + .update_with_attempts::("c", 3, |current| { + calls += 1; + rival.put("c", &counter(100 * calls)).unwrap(); + current + }) + .unwrap(); + assert_eq!(calls, 3); + assert_eq!(result.outcome, CultUpdateOutcome::LostRace); + // The pull before LostRace shows the value that won. + assert_eq!(result.document, Some(counter(300))); + assert_eq!(stored(&path, "c"), Some(300)); +} + +#[test] +fn max_attempts_bounds() { + let dir = tempfile::tempdir().unwrap(); + let path = path_in(&dir); + let mut cache = open(&path); + cache.put("c", &counter(1)).unwrap(); + let mut rival = open(&path); + // Always loses; panics past 10 calls so a weakened bound fails here instead of hanging. + let mut losing = |calls: &Cell| { + let n = calls.get() + 1; + calls.set(n); + assert!(n <= 10, "apply ran past the bound"); + rival.put("c", &counter(1000 + n)).unwrap(); + Some(counter(n)) + }; + + let calls = Cell::new(0); + let one = cache.update_with_attempts::("c", 1, |_| losing(&calls)).unwrap(); + assert_eq!((calls.get(), one.outcome), (1, CultUpdateOutcome::LostRace)); + + let calls = Cell::new(0); + assert!(cache.update_with_attempts::("c", 0, |_| losing(&calls)).is_err()); + assert_eq!(calls.get(), 0); + + // The default bound is 5. + let calls = Cell::new(0); + let default = cache.update::("c", |_| losing(&calls)).unwrap(); + assert_eq!((calls.get(), default.outcome), (5, CultUpdateOutcome::LostRace)); +} + +#[test] +fn none_declines_and_writes_nothing() { + let dir = tempfile::tempdir().unwrap(); + let path = path_in(&dir); + let mut cache = open(&path); + let absent = cache.update::("c", |current| { + assert_eq!(current, None); + None + }); + let absent = absent.unwrap(); + assert_eq!((absent.outcome, absent.document), (CultUpdateOutcome::Declined, None)); + assert_eq!(file(&path), None, "a declined update created the store"); + + cache.put("c", &counter(1)).unwrap(); + let before = file(&path); + let held = cache.update::("c", |_| None).unwrap(); + assert_eq!((held.outcome, held.document), (CultUpdateOutcome::Declined, Some(counter(1)))); + assert_eq!(file(&path), before); +} + +#[test] +fn a_declined_update_installs_nothing() { + let dir = tempfile::tempdir().unwrap(); + let path = path_in(&dir); + let mut cache = open(&path); + cache.put("c", &counter(1)).unwrap(); + let mut rival = open(&path); + rival.put("c", &counter(7)).unwrap(); + let mut seen = None; + let result = cache + .update::("c", |current| { + seen = current; + None + }) + .unwrap(); + assert_eq!(seen, Some(counter(7)), "apply saw the held copy, not the store's"); + assert_eq!(result.document, Some(counter(1))); + assert_eq!(cache.get::("c").unwrap(), Some(counter(1))); + cache.pull_all_backing_stores().unwrap(); + assert_eq!(cache.get::("c").unwrap(), Some(counter(7))); +} + +#[test] +fn absent_key_rival_removal_and_a_store_holding_nothing() { + // Two writers creating one absent key. + let dir = tempfile::tempdir().unwrap(); + let path = path_in(&dir); + let start = Arc::new(Barrier::new(2)); + let threads: Vec<_> = (0..2) + .map(|_| { + let (path, start) = (path.clone(), start.clone()); + std::thread::spawn(move || { + let mut cache = open(&path); + start.wait(); + let result = cache + .update_with_attempts::("new", 20, |current| { + std::thread::sleep(std::time::Duration::from_millis(1)); + Some(counter(current.map_or(0, |held| held.n) + 1)) + }) + .unwrap(); + assert_eq!(result.outcome, CultUpdateOutcome::Updated); + }) + }) + .collect(); + for thread in threads { + thread.join().unwrap(); + } + assert_eq!(stored(&path, "new"), Some(2)); + + // A rival deletes the record inside apply: the retry sees nothing. + let dir = tempfile::tempdir().unwrap(); + let path = path_in(&dir); + let mut cache = open(&path); + cache.put("c", &counter(1)).unwrap(); + let mut rival = open(&path); + let mut seen = Vec::new(); + let result = cache + .update::("c", |current| { + if seen.is_empty() { + assert!(rival.delete::("c").unwrap()); + } + seen.push(current.clone()); + Some(counter(current.map_or(0, |held| held.n) + 1)) + }) + .unwrap(); + assert_eq!(seen, [Some(counter(1)), None]); + assert_eq!(result.outcome, CultUpdateOutcome::Updated); + assert_eq!(stored(&path, "c"), Some(1)); + + // The cache holds a record the store no longer has: apply is handed None. + let dir = tempfile::tempdir().unwrap(); + let path = path_in(&dir); + let mut cache = open(&path); + cache.put("c", &counter(5)).unwrap(); + assert!(open(&path).delete::("c").unwrap()); + let mut seen = Vec::new(); + cache + .update::("c", |current| { + seen.push(current); + Some(counter(1)) + }) + .unwrap(); + assert_eq!(seen, [None]); + assert_eq!(stored(&path, "c"), Some(1)); +} + +#[test] +fn a_rival_writing_and_restoring_the_value_still_reruns_apply() { + let dir = tempfile::tempdir().unwrap(); + let path = path_in(&dir); + let mut cache = open(&path); + cache.put("c", &counter(1)).unwrap(); + let mut rival = open(&path); + let mut seen = Vec::new(); + cache + .update::("c", |current| { + let current = current.unwrap().n; + if seen.is_empty() { + rival.put("c", &counter(5)).unwrap(); + rival.put("c", &counter(1)).unwrap(); + } + seen.push(current); + Some(counter(current + 1)) + }) + .unwrap(); + assert_eq!(seen, [1, 1]); + assert_eq!(stored(&path, "c"), Some(2)); +} + +#[test] +fn a_stale_caches_first_attempt_reads_the_store() { + let dir = tempfile::tempdir().unwrap(); + let path = path_in(&dir); + let mut a = open(&path); + a.put("c", &counter(1)).unwrap(); + let mut b = open(&path); + a.put("c", &counter(7)).unwrap(); + let mut seen = Vec::new(); + b.update::("c", |current| { + seen.push(current.clone()); + current.map(|held| counter(held.n + 1)) + }) + .unwrap(); + assert_eq!(seen, [Some(counter(7))]); + assert_eq!(stored(&path, "c"), Some(8)); +} + +/// A custom store over the single-file store that has no `read` or `commit_if`. +struct ReadlessStore(SingleFileMessagePackBackingStore); + +impl CacheBackingStore for ReadlessStore { + fn pull_all(&self) -> anyhow::Result> { + self.0.pull_all() + } + fn push(&mut self, entry: &CultCacheEnvelope) -> anyhow::Result { + self.0.push(entry) + } + fn delete(&mut self, entry: &CultCacheEnvelope) -> anyhow::Result<()> { + self.0.delete(entry) + } + fn push_all(&mut self, entries: &[CultCacheEnvelope], options: PushAllOptions) -> anyhow::Result> { + self.0.push_all(entries, options) + } +} + +/// Runs `update` with an apply that counts its calls; the refusal must come before the first. +fn refused(cache: &mut CultCache, path: &Path, key: &str) -> String { + let before = file(path); + let calls = Cell::new(0); + let error = cache + .update::(key, |current| { + calls.set(calls.get() + 1); + current + }) + .expect_err("the update must be refused"); + assert_eq!(calls.get(), 0, "apply ran before the refusal: {error:#}"); + assert_eq!(file(path), before, "a refused update touched the store"); + format!("{error:#}") +} + +#[test] +fn refusals_come_before_apply() { + // Another type's record written at the key since the pull: the read resolves it. + let dir = tempfile::tempdir().unwrap(); + let path = path_in(&dir); + let mut cache = open(&path); + open(&path).put("k", &Note { text: "n".into() }).unwrap(); + let text = refused(&mut cache, &path, "k"); + assert!(text.contains("update cannot copy it"), "{text}"); + + // Another type held at the key. + let dir = tempfile::tempdir().unwrap(); + let path = path_in(&dir); + let mut cache = open(&path); + cache.put("k", &Note { text: "n".into() }).unwrap(); + refused(&mut cache, &path, "k"); + + // A cache with no store holds another type at the key. + let mut memory = CultCache::new(); + memory.register_entry_type::().unwrap(); + memory.register_entry_type::().unwrap(); + memory.put("k", &Note { text: "n".into() }).unwrap(); + let calls = Cell::new(0); + assert!(memory + .update::("k", |current| { + calls.set(calls.get() + 1); + current + }) + .is_err()); + assert_eq!(calls.get(), 0); + + // A foreign record (no registered type claims it) held at the key, and one that appears after the pull. + let dir = tempfile::tempdir().unwrap(); + let path = path_in(&dir); + open(&path).put("k", &Note { text: "n".into() }).unwrap(); + let mut only_counter = CultCache::new(); + only_counter.register_entry_type::().unwrap(); + only_counter.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap(); + only_counter.pull_all_backing_stores().unwrap(); + refused(&mut only_counter, &path, "k"); + let mut later = CultCache::new(); + later.register_entry_type::().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = path_in(&dir); + later.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap(); + later.pull_all_backing_stores().unwrap(); + open(&path).put("z", &Note { text: "n".into() }).unwrap(); + let text = refused(&mut later, &path, "z"); + assert!(text.contains("update cannot copy it"), "{text}"); + + // A redb home and a custom store without read. + let dir = tempfile::tempdir().unwrap(); + let redb_path = dir.path().join("store.redb"); + let mut redb = CultCache::new(); + redb.register_entry_type::().unwrap(); + redb.add_generic_backing_store(RedbMessagePackBackingStore::new(&redb_path).unwrap()).unwrap(); + redb.pull_all_backing_stores().unwrap(); + redb.put("c", &counter(1)).unwrap(); + let text = refused(&mut redb, &redb_path, "c"); + assert!(text.contains("read and commit_if"), "{text}"); + let path = path_in(&dir); + let mut custom = CultCache::new(); + custom.register_entry_type::().unwrap(); + custom.add_generic_backing_store(ReadlessStore(SingleFileMessagePackBackingStore::new(&path))).unwrap(); + custom.pull_all_backing_stores().unwrap(); + custom.put("c", &counter(1)).unwrap(); + let text = refused(&mut custom, &path, "c"); + assert!(text.contains("read and commit_if"), "{text}"); + + // A held read-only record, and a store record with slots the cache never loaded. + let dir = tempfile::tempdir().unwrap(); + let path = path_in(&dir); + let mut wide = CultCache::new(); + wide.register_entry_type::().unwrap(); + wide.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap(); + wide.pull_all_backing_stores().unwrap(); + wide.put("held", &WideCounter { n: 1, extra: 2 }).unwrap(); + let mut cache = open(&path); + let text = refused(&mut cache, &path, "held"); + assert!(text.contains("held"), "{text}"); + wide.put("unloaded", &WideCounter { n: 1, extra: 2 }).unwrap(); + refused(&mut cache, &path, "unloaded"); + + // An unpulled cache. + let mut unpulled = CultCache::new(); + unpulled.register_entry_type::().unwrap(); + unpulled.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap(); + refused(&mut unpulled, &path, "held"); +} + +#[test] +fn a_refusal_after_apply_is_not_retried() { + let dir = tempfile::tempdir().unwrap(); + let path = path_in(&dir); + let mut cache = open(&path); + cache.put("a", &Sku { sku: "S".into() }).unwrap(); + cache.put("b", &Sku { sku: "T".into() }).unwrap(); + let before = file(&path); + let calls = Cell::new(0); + let error = cache + .update::("b", |_| { + calls.set(calls.get() + 1); + Some(Sku { sku: "S".into() }) + }) + .expect_err("a unique-index clash"); + assert_eq!(calls.get(), 1, "{error:#}"); + assert_eq!(file(&path), before); +} + +#[test] +fn own_writes_and_the_store_less_cache() { + let dir = tempfile::tempdir().unwrap(); + let path = path_in(&dir); + let mut cache = open(&path); + cache.put("c", &counter(1)).unwrap(); + let calls = Cell::new(0); + for expected in [2, 3] { + let result = cache + .update::("c", |current| { + calls.set(calls.get() + 1); + current.map(|held| counter(held.n + 1)) + }) + .unwrap(); + assert_eq!(result.outcome, CultUpdateOutcome::Updated); + assert_eq!(result.document, Some(counter(expected))); + assert_eq!(result.document, cache.get::("c").unwrap()); + } + assert_eq!(calls.get(), 2); + + let mut memory = CultCache::new(); + memory.register_entry_type::().unwrap(); + let calls = Cell::new(0); + let result = memory + .update::("c", |current| { + calls.set(calls.get() + 1); + Some(counter(current.map_or(0, |held| held.n) + 1)) + }) + .unwrap(); + assert_eq!((calls.get(), result.outcome, result.document), (1, CultUpdateOutcome::Updated, Some(counter(1)))); +} + +#[test] +fn the_ledger_holds_under_contention() { + let dir = tempfile::tempdir().unwrap(); + let path = path_in(&dir); + let start = Arc::new(Barrier::new(3)); + let threads: Vec<_> = (0..3) + .map(|_| { + let (path, start) = (path.clone(), start.clone()); + std::thread::spawn(move || { + let mut cache = open(&path); + start.wait(); + (0..25) + .filter(|_| { + let result = cache + .update::("c", |current| Some(counter(current.map_or(0, |held| held.n) + 1))) + .unwrap(); + result.outcome == CultUpdateOutcome::Updated + }) + .count() as u32 + }) + }) + .collect(); + let updated: u32 = threads.into_iter().map(|thread| thread.join().unwrap()).sum(); + assert_eq!(stored(&path, "c"), Some(updated)); +} + +fn envelope_of(cache: &CultCache, key: &str, value: &T, stamp: &str) -> CultCacheEnvelope { + let (mut entry, _) = cache.prepare_entry(key, value).unwrap(); + entry.stored_at = stamp.to_string(); + entry +} + +const FUTURE: &str = "2099-01-01T00:00:00Z"; + +fn later_than_future(stamp: &str) -> bool { + chrono::DateTime::parse_from_rfc3339(stamp).unwrap() > chrono::DateTime::parse_from_rfc3339(FUTURE).unwrap() +} + +#[test] +fn commit_if_writes_only_over_the_version_it_was_given() { + let dir = tempfile::tempdir().unwrap(); + let path = path_in(&dir); + let cache = open(&path); + let mut store = SingleFileMessagePackBackingStore::new(&path); + assert_eq!(store.read("c").unwrap(), None); + let one = envelope_of(&cache, "c", &counter(1), FUTURE); + // Against an absent key, expecting a version mismatches; expecting nothing commits. + assert_eq!(store.commit_if(&one, Some("0000")).unwrap(), CultCommitIf::Mismatch); + assert_eq!(file(&path), None); + assert_eq!(store.commit_if(&one, None).unwrap(), CultCommitIf::Committed { stored_at: FUTURE.to_string() }); + let CultStoredRecord { envelope, version } = store.read("c").unwrap().unwrap(); + assert_eq!(envelope.stored_at, FUTURE); + + let before = file(&path); + let two = envelope_of(&cache, "c", &counter(2), FUTURE); + assert_eq!(store.commit_if(&two, None).unwrap(), CultCommitIf::Mismatch); + assert_eq!(store.commit_if(&two, Some("not-the-version")).unwrap(), CultCommitIf::Mismatch); + assert_eq!(file(&path), before, "a mismatch wrote"); + let CultCommitIf::Committed { stored_at } = store.commit_if(&two, Some(&version)).unwrap() else { panic!("the version matches") }; + assert!(later_than_future(&stored_at), "{stored_at}"); + assert_eq!(stored(&path, "c"), Some(2)); +} + +#[test] +fn the_cache_facing_writes_stamp_over_a_replaced_record() { + let seeded = |dir: &tempfile::TempDir| { + let path = path_in(dir); + let seed = open(&path); + let mut store = SingleFileMessagePackBackingStore::new(&path); + store.push(&envelope_of(&seed, "c", &counter(1), FUTURE)).unwrap(); + (path, seed) + }; + let on_disk = |path: &Path| { + let mut store = SingleFileMessagePackBackingStore::new(path); + let stamp = store.read("c").unwrap().unwrap().envelope.stored_at; + let _ = &mut store; + stamp + }; + + // The store: push, push_all and commit_if each return the stamp they wrote, strictly later than what they replaced. + let dir = tempfile::tempdir().unwrap(); + let (path, seed) = seeded(&dir); + let mut store = SingleFileMessagePackBackingStore::new(&path); + let stamp = store.push(&envelope_of(&seed, "c", &counter(2), FUTURE)).unwrap(); + assert!(later_than_future(&stamp) && stamp == on_disk(&path), "push: {stamp}"); + let stamps = store.push_all(&[envelope_of(&seed, "c", &counter(3), FUTURE)], PushAllOptions::default()).unwrap(); + assert!(later_than_future(&stamps[0]) && stamps[0] == on_disk(&path), "push_all: {stamps:?}"); + let version = store.read("c").unwrap().unwrap().version; + let CultCommitIf::Committed { stored_at } = store.commit_if(&envelope_of(&seed, "c", &counter(4), FUTURE), Some(&version)).unwrap() + else { + panic!("matching version") + }; + assert!(later_than_future(&stored_at) && stored_at == on_disk(&path), "commit_if: {stored_at}"); + // One stamp per input, in order, a duplicate getting the stamp of the entry that landed; a soft write that wrote nothing none. + let stamps = store + .push_all( + &[envelope_of(&seed, "c", &counter(5), FUTURE), envelope_of(&seed, "d", &counter(6), FUTURE), envelope_of(&seed, "c", &counter(7), FUTURE)], + PushAllOptions::default(), + ) + .unwrap(); + assert_eq!(stamps.len(), 3); + assert_eq!(stamps[0], stamps[2]); + assert_eq!(stamps[1], FUTURE, "a record replacing nothing keeps its stamp"); + assert_eq!(stamps[0], on_disk(&path)); + assert!(store.push_all(&[envelope_of(&seed, "e", &counter(1), FUTURE)], PushAllOptions { soft: true }).unwrap().is_empty()); + + // The cache holds the stamp the store wrote after every write entry. + type Write = fn(&mut CultCache) -> (); + let writes: [(&str, Write); 5] = [ + ("put", |cache| drop(cache.put("c", &counter(9)).unwrap())), + ("put_envelope", |cache| { + let entry = envelope_of(cache, "c", &counter(9), FUTURE); + drop(cache.put_envelope::(entry).unwrap()) + }), + ("put_raw_envelope", |cache| { + let entry = envelope_of(cache, "c", &counter(9), FUTURE); + cache.put_raw_envelope(entry).unwrap() + }), + ("put_prepared_batch", |cache| { + let entry = envelope_of(cache, "c", &counter(9), FUTURE); + cache.put_prepared_batch(vec![entry]).unwrap() + }), + ("update", |cache| drop(cache.update::("c", |_| Some(counter(9))).unwrap())), + ]; + for (name, write) in writes { + let dir = tempfile::tempdir().unwrap(); + let (path, _) = seeded(&dir); + let mut cache = open(&path); + write(&mut cache); + let held = cache.get_envelope::("c").unwrap().unwrap().stored_at; + assert!(later_than_future(&held), "{name}: {held}"); + assert_eq!(held, on_disk(&path), "{name}: the cache holds a stamp the store did not write"); + } +} + +#[test] +fn the_byte_exact_writers_keep_the_callers_stamp() { + let dir = tempfile::tempdir().unwrap(); + let path = path_in(&dir); + let cache = open(&path); + let mut store = SingleFileMessagePackBackingStore::new(&path); + let held = envelope_of(&cache, "c", &counter(1), FUTURE); + store.push(&held).unwrap(); + let swapped = envelope_of(&cache, "c", &counter(2), "2026-01-01T00:00:00Z"); + assert!(store.compare_and_swap_entry(&held, swapped.clone()).unwrap()); + assert_eq!(store.read("c").unwrap().unwrap().envelope, swapped, "compare_and_swap_entry restamped"); + + let redb_path = dir.path().join("store.redb"); + let mut redb = RedbMessagePackBackingStore::new(&redb_path).unwrap(); + redb.push(&held).unwrap(); + let older = envelope_of(&cache, "c", &counter(3), "2026-01-01T00:00:00Z"); + assert_eq!(redb.push(&older).unwrap(), older.stored_at); + assert_eq!(redb.pull_all().unwrap(), vec![older.clone()]); + assert_eq!(redb.push_all(std::slice::from_ref(&held), PushAllOptions::default()).unwrap(), vec![held.stored_at.clone()]); + assert_eq!(redb.pull_all().unwrap(), vec![held]); +} + +/// A single-file store that reports whatever stamp it is told to, as a custom store might. +struct ReportingStore { + inner: SingleFileMessagePackBackingStore, + report: String, +} + +impl CacheBackingStore for ReportingStore { + fn pull_all(&self) -> anyhow::Result> { + self.inner.pull_all() + } + fn push(&mut self, entry: &CultCacheEnvelope) -> anyhow::Result { + self.inner.push(entry)?; + Ok(self.report.clone()) + } + fn delete(&mut self, entry: &CultCacheEnvelope) -> anyhow::Result<()> { + self.inner.delete(entry) + } + fn push_all(&mut self, entries: &[CultCacheEnvelope], options: PushAllOptions) -> anyhow::Result> { + self.inner.push_all(entries, options)?; + Ok(entries.iter().map(|_| self.report.clone()).collect()) + } + fn read(&self, key: &str) -> anyhow::Result> { + self.inner.read(key) + } + fn commit_if(&mut self, entry: &CultCacheEnvelope, expected: Option<&str>) -> anyhow::Result { + Ok(match self.inner.commit_if(entry, expected)? { + CultCommitIf::Committed { .. } => CultCommitIf::Committed { stored_at: self.report.clone() }, + CultCommitIf::Mismatch => CultCommitIf::Mismatch, + }) + } +} + +#[test] +fn the_cache_holds_a_reported_stamp_only_when_it_parses_as_rfc3339() { + type Write = fn(&mut CultCache); + let writes: [(&str, Write); 4] = [ + ("put", |cache| drop(cache.put("c", &counter(1)).unwrap())), + ("put_raw_envelope", |cache| { + let entry = envelope_of(cache, "c", &counter(1), "2020-01-01T00:00:00Z"); + cache.put_raw_envelope(entry).unwrap() + }), + ("put_prepared_batch", |cache| { + let entry = envelope_of(cache, "c", &counter(1), "2020-01-01T00:00:00Z"); + cache.put_prepared_batch(vec![entry]).unwrap() + }), + ("update", |cache| drop(cache.update::("c", |_| Some(counter(1))).unwrap())), + ]; + for (report, parses) in [ + ("2030-01-01T00:00:00Z", true), + ("2030-01-01T00:00:00.123456+00:00", true), + ("not a stamp", false), + ("", false), + ("2030-01-01", false), + ("2030-01-01 00:00:00", false), + ("2030-01-01T00:00:00", false), + ("1893456000", false), + ] { + for (name, write) in writes { + let dir = tempfile::tempdir().unwrap(); + let path = path_in(&dir); + let mut cache = CultCache::new(); + cache.register_entry_type::().unwrap(); + cache + .add_generic_backing_store(ReportingStore { inner: SingleFileMessagePackBackingStore::new(&path), report: report.to_string() }) + .unwrap(); + cache.pull_all_backing_stores().unwrap(); + write(&mut cache); + let held = cache.get_envelope::("c").unwrap().unwrap().stored_at; + if parses { + assert_eq!(held, report, "{name}"); + } else { + assert_ne!(held, report, "{name} held a stamp that is not RFC 3339"); + assert!(chrono::DateTime::parse_from_rfc3339(&held).is_ok(), "{name}: {held}"); + } + } + } +} + +#[test] +fn a_stamp_with_no_successor_refuses_every_single_file_write_and_writes_nothing() { + let dir = tempfile::tempdir().unwrap(); + let path = path_in(&dir); + let cache = open(&path); + let mut store = SingleFileMessagePackBackingStore::new(&path); + let last = "9999-12-31T23:59:59.999999Z"; + store.push(&envelope_of(&cache, "c", &counter(1), last)).unwrap(); + let before = file(&path); + let version = store.read("c").unwrap().unwrap().version; + let next = envelope_of(&cache, "c", &counter(2), "2020-01-01T00:00:00Z"); + assert!(store.push(&next).is_err()); + assert!(store.push_all(std::slice::from_ref(&next), PushAllOptions::default()).is_err()); + assert!(store.commit_if(&next, Some(&version)).is_err()); + assert_eq!(file(&path), before, "a refused mint wrote"); +} + +#[test] +fn commit_if_refuses_a_retype_as_push_does() { + let dir = tempfile::tempdir().unwrap(); + let path = path_in(&dir); + let cache = open(&path); + let mut store = SingleFileMessagePackBackingStore::new(&path); + let note = envelope_of(&cache, "k", &Note { text: "n".into() }, "2020-01-01T00:00:00Z"); + store.push(¬e).unwrap(); + let version = store.read("k").unwrap().unwrap().version; + let before = file(&path); + let counter_at_k = envelope_of(&cache, "k", &counter(1), "2020-01-01T00:00:00Z"); + let error = store.commit_if(&counter_at_k, Some(&version)).expect_err("a retype"); + assert!(error.downcast_ref::().is_some(), "{error:#}"); + assert!(store.push(&counter_at_k).is_err()); + assert_eq!(file(&path), before, "a refused retype wrote"); + assert_eq!(store.read("k").unwrap().unwrap().envelope.r#type, "note"); +} diff --git a/packages/cultmesh-rs/Cargo.lock b/packages/cultmesh-rs/Cargo.lock index de7c506e..c45da112 100644 --- a/packages/cultmesh-rs/Cargo.lock +++ b/packages/cultmesh-rs/Cargo.lock @@ -213,6 +213,7 @@ dependencies = [ "serde_bytes", "serde_json", "serde_stacker", + "sha2", "stacker", "uuid", "windows-sys 0.59.0", diff --git a/packages/cultnet-rs/Cargo.lock b/packages/cultnet-rs/Cargo.lock index ed0aab43..e1af9bd6 100644 --- a/packages/cultnet-rs/Cargo.lock +++ b/packages/cultnet-rs/Cargo.lock @@ -195,6 +195,7 @@ dependencies = [ "serde_bytes", "serde_json", "serde_stacker", + "sha2", "stacker", "uuid", "windows-sys 0.59.0", diff --git a/packages/cultnet-rs/src/idunn_runtime_provider/tests.rs b/packages/cultnet-rs/src/idunn_runtime_provider/tests.rs index 9f2d53ec..31760f2c 100644 --- a/packages/cultnet-rs/src/idunn_runtime_provider/tests.rs +++ b/packages/cultnet-rs/src/idunn_runtime_provider/tests.rs @@ -51,6 +51,7 @@ fn write_record(path: &Path, key: &str, ty: &str, schema: &str, payload: Vec stored_at: "2026-10-29T00:00:00Z".into(), schema_id: Some(schema.into()), }) + .map(|_| ()) } // ------------------------------------------------------------------ fake host diff --git a/packages/cultnet-rs/src/service_identity.rs b/packages/cultnet-rs/src/service_identity.rs index 63ec29f6..8ea978c8 100644 --- a/packages/cultnet-rs/src/service_identity.rs +++ b/packages/cultnet-rs/src/service_identity.rs @@ -347,7 +347,9 @@ pub fn export_service_identity_trust_anchor( }; let mut backing = SingleFileMessagePackBackingStore::new(path); match backing.pull_all()?.as_slice() { - [] => backing.push(&envelope)?, + [] => { + backing.push(&envelope)?; + } [current] if current == &envelope => {} _ => bail!("service identity trust anchor output already contains different state"), } From c77d734da5936e8070d76712bf7bb2048ae8a5b8 Mon Sep 17 00:00:00 2001 From: Libby Date: Sat, 14 Nov 2026 03:21:37 +0000 Subject: [PATCH 91/93] cultcache-py: update-with-retry (update-with-retry-py) (#172) --- docs/start/README.md | 25 +- packages/cultcache-py/CHANGELOG.md | 4 + packages/cultcache-py/README.md | 5 +- .../cultcache-py/src/cultcache_py/__init__.py | 4 +- .../src/cultcache_py/backing_store.py | 11 +- .../cultcache-py/src/cultcache_py/cache.py | 194 ++++- .../cultcache-py/src/cultcache_py/stores.py | 121 ++- .../tests/test_content_version_vector.py | 38 + packages/cultcache-py/tests/test_cultcache.py | 2 +- .../cultcache-py/tests/test_first_call.py | 37 +- .../cultcache-py/tests/test_unique_index.py | 1 - packages/cultcache-py/tests/test_update.py | 731 ++++++++++++++++++ packages/cultmesh-py/src/cultmesh_py/node.py | 13 +- .../cultmesh-py/src/cultmesh_py/server.py | 4 +- packages/cultmesh-py/tests/test_cultmesh.py | 46 ++ .../tests/test_remote_write_authority.py | 27 +- 16 files changed, 1199 insertions(+), 64 deletions(-) create mode 100644 packages/cultcache-py/tests/test_content_version_vector.py create mode 100644 packages/cultcache-py/tests/test_update.py diff --git a/docs/start/README.md b/docs/start/README.md index a86f4732..c478467c 100644 --- a/docs/start/README.md +++ b/docs/start/README.md @@ -19,7 +19,7 @@ this page. | **C#** | [first call, tested](#c-first-call); [update, tested](#c-update-with-retry) | [package](../../packages/cultmath/README.md); snippet not yet | [package](../../src/GameCult.Geometry/README.md); snippet not yet | [first call, tested](#c-cultnet-first-call); [package](../../src/GameCult.Networking) | [quickstart](../../src/GameCult.Mesh/docs/durable-node-quickstart.md); run by `scripts/verify-eve-getting-started.ps1` | | **TypeScript** | [first call, tested](#typescript-first-call); [update, tested](#typescript-update-with-retry) | not yet | not yet | [first call, tested](#typescript-cultnet-first-call); [package](../../packages/cultnet-ts/README.md) | [package](../../packages/cultmesh-ts/README.md); snippet not yet; [recipe: two-client .io game](io-game.md), tested with two live clients, browser page not yet run | | **Rust** | [first call, tested](#rust-first-call); [update, tested](#rust-update-with-retry) | [native core](../../packages/cultmath/native/cultmath-core); snippet not yet | not yet | [first call, tested](#rust-cultnet-first-call); [package](../../packages/cultnet-rs/README.md), [examples](../../packages/cultnet-rs/examples) | [package](../../packages/cultmesh-rs); snippet not yet | -| **Python** | [first call, tested](#python-first-call) | not yet | not yet | [first call, tested](#python-cultnet-first-call); [package](../../packages/cultnet-py/README.md) | [package](../../packages/cultmesh-py/README.md); snippet not yet | +| **Python** | [first call, tested](#python-first-call); [update, tested](#python-update-with-retry) | not yet | not yet | [first call, tested](#python-cultnet-first-call); [package](../../packages/cultnet-py/README.md) | [package](../../packages/cultmesh-py/README.md); snippet not yet | For the whole picture of what each piece is for, see the @@ -305,6 +305,29 @@ def first_call() -> Item: return reopened.get_required(item_doc, "item:potion") ``` +## Python update with retry + +`update` changes a record from what the store holds now and commits only if it is still that record. The source is the +`update` region of +[`packages/cultcache-py/tests/test_first_call.py`](../../packages/cultcache-py/tests/test_first_call.py), which uses the +definitions of the first call above; a test in that file fails if the block stops matching the file. + +```python +def restock(path: Path): + cache = ( + CultCache.builder() + .register_document_type(item_doc) + .add_generic_store(SingleFileMessagePackBackingStore(path)) + .build() + ) + cache.pull_all_backing_stores() + return cache.update(item_doc, "item:potion", lambda item: Item("Potion", "Consumable", (item.value if item else 0) + 10)) +``` + +The write is durable when `update` returns. A lost race re-runs the function, so keep it free of side effects; when the +attempts run out the result's `outcome` is `UpdateOutcome.LOST_RACE` instead of a raise. `put` is the blind opt-out: last +writer wins. + ## CultNet first calls CultNet is schema-v0 messages in length-prefixed frames. The first call in each runtime is the same diff --git a/packages/cultcache-py/CHANGELOG.md b/packages/cultcache-py/CHANGELOG.md index 77324c91..8f3e481d 100644 --- a/packages/cultcache-py/CHANGELOG.md +++ b/packages/cultcache-py/CHANGELOG.md @@ -4,10 +4,12 @@ ### Added +- `CultCache.update` and `update_global` return `UpdateResult(outcome, document)`; `UpdateOutcome` is `UPDATED`, `DECLINED` or `LOST_RACE`. The single-file store gains `read(key)` (the record and its content version, the SHA-256 C# computes) and `commit_if(envelope, expected)`, the version is the SHA-256 of the record re-encoded as `[key, schemaId, storedAt, payload]` with minimal MessagePack encodings, which is the SHA-256 of the record's stored bytes exactly when the writer encoded it minimally (every writer in this library does; a record another writer encoded with a wider-than-needed length or integer has the version of its minimal form, not of its bytes). Both looked up by the cache and not `BackingStore` protocol members (variants:cut_spec:cut-update-with-retry-py). - `read_payload_tree(type_name, payload, decoder=None)`: the exported owner of the one-value, depth-first payload read that `decode_payload` uses, for payloads read outside a definition (`cultnet-py`'s witness bundle decode needs it, so `cultnet-py` now requires `cultcache-py>=0.4.0`). The manifest version is 0.4.0, the minor these Unreleased breaking changes imply, set in this change so the workspace install resolves the new bound; the `## Unreleased` heading becomes `## [0.4.0]` when it is tagged. ### Changed +- A write that replaces a record is stored at a later `stored_at` than the record it replaces, in the single-file store, the JSONL store and a store-less cache, with no exception for identical content (`variants:ruling:py-rs-update-follow-csharp-restamp`); the cache holds the stamp the store wrote. A stored_at is read only in the shape the runtimes write (an RFC 3339 date-time with T, a fraction of 1 to 9 digits, Z or a numeric offset), the same on every Python; any other text, or an instant outside `datetime`'s range, is not a stamp and a write over it is stamped at now. A write over a record stamped at the last instant `datetime` can hold (9999-12-31T23:59:59.999999Z) is refused with `ValueError` and writes nothing. - `store_write_lock` looks for a link at the store path again once the lock is held, so a link put at the path while a writer waited on the lock is refused with `StoreLinkedError` and the lock released, instead of being replaced. `JsonLinesBackingStore` takes no cross-process lock (only its thread lock, under which it already refuses a link), so it has no lock wait to re-check across. Pinned by a test that puts the link while a foreign process holds the lock. - `CultCache.is_writable` does not look at store paths: a link at a store path is not predicted, and the write refuses it with `StoreLinkedError`. Pinned by a test through a cache attached to a linked store. - `JsonLinesBackingStore` writes LF line endings on Windows. It wrote CRLF there, through `write_text` newline translation, before the commit_store_file change (#154, adbbb780). @@ -17,6 +19,8 @@ ### Breaking +- `update(document, key, apply, *, max_attempts=5)` and `update_global(document, apply, *, max_attempts=5)` replace the blind pair. Each attempt reads the record from the home store, runs `apply` on a copy (None when absent) outside every lock, and commits only if the record is unchanged, running `apply` again on a lost race; it returns `UpdateResult`, not the value. `apply` returning None writes nothing; a lost race that outlasts `max_attempts` returns `LOST_RACE`; a store without `read` and `commit_if` (JSON lines among them) refuses before `apply`. `put` is the blind write. +- `push` returns the `stored_at` it wrote and `push_all` a dict of them by key (`{}` when a soft push skipped); a store returning None keeps the cache's own stamp. - `push_all(envelopes, soft=False)` on the single-file and JSONL stores and on the `BackingStore` protocol takes the soft flag, as TypeScript, Rust and C# do: a soft push writes only where nothing is at the path; over a store the reader reads, empty included, it returns without writing, and over a file the reader cannot read it refuses (`StoreUnreadableError` for the single-file store). The `BackingStore` protocol has `pull_or_absent()`: the envelopes the reader reads, `None` when nothing is at the path, a raise when the path cannot be read; its default is `pull_all()`, which cannot say absent, so a store that inherits it is always read. The default `push_all` decides soft by it (writes where it says absent, skips over a store it read, refuses where it raises), and the single-file and JSONL stores expose their pull through it; an inheriting store that can tell nothing-at-the-path overrides `pull_or_absent`, not `push_all`. Before, a soft push through the default always skipped. A store kind that overrides `push_all(envelopes)` without the keyword fails when a caller passes `soft`. - A write at a store path that is a link, live or dangling, is refused with the new `StoreLinkedError` (an `OSError`, exported from `cultcache_py`; `.path` names the path) before the lock and the replace, for the single-file store and for `JsonLinesBackingStore` (`push`, `push_all`, `delete`), and nothing is written. Before, a write at a live link replaced the link with a regular file, and at a dangling link both stores raised `FileNotFoundError`. A caller catching `FileNotFoundError` there now catches `StoreLinkedError` (`OSError`); configure the link's target as the store path. On Windows a junction or any reparse point counts as a link. Reads through a live link work. - A record whose payload carries a slot past its reader's declared slots, at any depth (the document, a nested record, a list element, a union arm, a unit arm), is read, held read-only, and every write or removal of its key (`put`, `put_global`, `update`, `put_envelope`, `put_envelopes`, `delete`, `delete_global`) is refused with `SchemaConflictError` before any store is touched (`cultlib-gaps:ruling:extra-trailing-slots`). Reading dropped those slots silently and the next write shed them. `SchemaConflictError.unread_slots` is an `UnreadSlots(type_name, slot_count, declared_count, occurrences, depth0_only)` and the message names the type and both counts, never a slot value. A depth-0 mark is consented, and the record writable, only when the record is stored under an id the document lists in `compatible_schema_ids` and that id's catalog entry has slots past the document's, covering the mark's slot count (`cultlib-gaps:ruling:slot-mark-consent`); a nested mark, or a mark under the document's own id, keeps the record read-only whatever is declared. Missing trailing slots keep their declared defaults. An untouched marked record keeps its stored bytes through every other write. Added `CultCache.is_writable(document, key)` (advisory; the write still decides: it also answers False for a foreign-carried key, for a key held as a type the document does not own, and for a global document at a non-global key; `delete` asks the same judges, so removing a key held as another type is refused with `SchemaConflictError` unless the document owns that record's schema id (its own or one it lists compatible), in which case the removal removes that record, its file row and its lookups, as a write of the key would replace it; it returned silently and removed nothing), `DocumentDefinition.decode_payload_marked`, and `UnreadSlots`. A document with a custom `decode` (no slot codec) never marks. A store holding a variant record is still unreadable here. Implies a minor bump while the version is 0.x (0.3.0 to 0.4.0): code that wrote back a record from a newer writer now gets `SchemaConflictError`. diff --git a/packages/cultcache-py/README.md b/packages/cultcache-py/README.md index 4a7236fd..0a03a671 100644 --- a/packages/cultcache-py/README.md +++ b/packages/cultcache-py/README.md @@ -94,8 +94,7 @@ store. - `put(...)` - `put_envelopes(...)` - `put_global(...)` -- `update(...)` -- `update_global(...)` +- `update(document, key, apply, *, max_attempts=5)` and `update_global(document, apply, *, max_attempts=5)`: the conditional golden path; reads the record as its home store holds it now, runs `apply` on a fresh copy, writes only if the record is unchanged and runs `apply` again on a lost race; returns `UpdateResult(outcome, document)` with `UpdateOutcome.UPDATED`, `DECLINED` (`apply` returned `None`) or `LOST_RACE` (see below) - `delete(...)` - `delete_global(...)` - `is_writable(document, key)` (advisory: would a write or removal of the key be refused; see below) @@ -143,6 +142,8 @@ used by the TypeScript, Rust, and C# runtimes: canonicalSchemaJson, compatibleSchemaIds, members]` - records: `[key, schemaId, storedAt, payload]` +`update` needs a home store with the optional `read(key)` (the record at the key, of any type, and its content version, from one read) and `commit_if(envelope, expected)` (write only if the record at the key is still that version, under the store's write lock). The content version is the lowercase hex SHA-256 of the record re-encoded as the store writes it, `[key, schemaId, storedAt, payload]` with minimal MessagePack encodings, the same value C# computes (`tests/vectors/content-version`). It equals the SHA-256 of the record's bytes in the file exactly when the writer encoded the record minimally, as every writer in this library does; a record encoded with a wider-than-needed length or integer has the version of its minimal form. The single-file store has both; `JsonLinesBackingStore` and a store without them refuse `update` before `apply` runs. A cache with no store copies its held instance. Every write that replaces a record is stored at a later `stored_at` than the one it replaces, in each store kind and in a store-less cache; `push` and `push_all` return the stamp written, and the cache holds that stamp. + Large corpora should use a sharded store or a real database. A single snapshot file is a scalpel, not a forklift. ## Store format and rollback diff --git a/packages/cultcache-py/src/cultcache_py/__init__.py b/packages/cultcache-py/src/cultcache_py/__init__.py index a2752511..0c26cb25 100644 --- a/packages/cultcache-py/src/cultcache_py/__init__.py +++ b/packages/cultcache-py/src/cultcache_py/__init__.py @@ -1,7 +1,7 @@ from .backing_store import BackingStore, CultCacheEnvelope from .backing_store import CultCacheSchemaCatalogEntry, CultCacheSchemaCatalogMember from . import leaves -from .cache import CultCache, CultCacheBuilder, ForeignRecord +from .cache import CultCache, CultCacheBuilder, ForeignRecord, UpdateOutcome, UpdateResult from .documents import ( MAX_PAYLOAD_DEPTH, MAX_SLOT_KEY, @@ -42,6 +42,8 @@ __all__ = [ "StoreUnreadableError", "UnionArm", "UnreadSlots", + "UpdateOutcome", + "UpdateResult", "read_payload_tree", "leaves", "database_entry_field", diff --git a/packages/cultcache-py/src/cultcache_py/backing_store.py b/packages/cultcache-py/src/cultcache_py/backing_store.py index bded4b11..c20ac661 100644 --- a/packages/cultcache-py/src/cultcache_py/backing_store.py +++ b/packages/cultcache-py/src/cultcache_py/backing_store.py @@ -58,10 +58,17 @@ class CultCacheEnvelope: class BackingStore(Protocol): + """The members every store has. A store may also offer `read(key) -> tuple[CultCacheEnvelope, str] | None` (the record at the + key, of any type, and its content version, from one read) and `commit_if(envelope, expected: str | None) -> str | None` (write + only if the record at the key is still the version `expected`, None meaning no record; returns the stored_at written, or None + with nothing written). They are looked up with getattr, not Protocol members, and `CultCache.update` needs both. `push` and + `push_all` may report the stored_at they wrote (`str` and `dict[str, str]` by key); None keeps the cache's own stamp. A store + stamps a record that replaces another strictly later than the one it replaces.""" + def pull_all(self) -> list[CultCacheEnvelope]: ... - def push(self, envelope: CultCacheEnvelope) -> None: + def push(self, envelope: CultCacheEnvelope) -> str | None: ... def delete(self, type: str, key: str) -> None: @@ -74,7 +81,7 @@ class BackingStore(Protocol): that can tell nothing-at-the-path overrides this, and the default push_all then writes where nothing is on a soft push.""" return self.pull_all() - def push_all(self, envelopes: list[CultCacheEnvelope], soft: bool = False) -> None: + def push_all(self, envelopes: list[CultCacheEnvelope], soft: bool = False) -> dict[str, str] | None: # Soft: write only where pull_or_absent says nothing is; skip over a store it read, empty included; its raise refuses. pulled = self.pull_or_absent() if soft and pulled is not None: diff --git a/packages/cultcache-py/src/cultcache_py/cache.py b/packages/cultcache-py/src/cultcache_py/cache.py index 80a12706..0a396f61 100644 --- a/packages/cultcache-py/src/cultcache_py/cache.py +++ b/packages/cultcache-py/src/cultcache_py/cache.py @@ -1,5 +1,6 @@ from __future__ import annotations +import enum import functools import math import threading @@ -9,13 +10,39 @@ from typing import Any, Callable, Generic, Iterable, TypeVar from .backing_store import BackingStore, CultCacheEnvelope from .documents import DocumentDefinition, UnreadSlots, extract_value -from .stores import SchemaConflictError, retyped, written_owns +from .stores import SchemaConflictError, _parse_stored_at, retyped, stored_at_over, written_owns T = TypeVar("T") F = TypeVar("F", bound=Callable[..., Any]) GLOBAL_KEY = "__global__" +class UpdateOutcome(enum.Enum): + UPDATED = "updated" + DECLINED = "declined" + LOST_RACE = "lost_race" + + +@dataclass(frozen=True) +class UpdateResult(Generic[T]): + """What `CultCache.update` did and the cache's record at the key after it (None when it holds nothing). The document decides + nothing: it is a read after the outcome.""" + + outcome: UpdateOutcome + document: T | None + + +@dataclass(frozen=True) +class _Expected: + """What an update's commit requires to still hold. A cache with no store requires the held envelope object at the key (None: + nothing held); a store-backed one requires the type's home to be `store` and the record there to have content version + `version` (None: no record).""" + + held: CultCacheEnvelope | None = None + store: BackingStore | None = None + version: str | None = None + + def _locked(method: F) -> F: """Runs a read holding the cache's lock. Reads are reentrant and may run inside a mutation.""" @@ -482,7 +509,7 @@ class CultCache: def put(self, document: DocumentDefinition[T], key: str, value: T) -> None: self._put(document, key, value) - def _put(self, document: DocumentDefinition[T], key: str, value: T) -> None: + def _put(self, document: DocumentDefinition[T], key: str, value: T, expected: _Expected | None = None) -> str: self._assert_registered(document) if document.global_document and key != GLOBAL_KEY: raise CultCacheError(f"Global document {document.type} must use key {GLOBAL_KEY}") @@ -494,7 +521,7 @@ class CultCache: schema_id=catalog_entry.schema_id, catalog_entry=catalog_entry, ) - self._write(document, [(envelope, value)], batch=False) + return self._write(document, [(envelope, value)], batch=False, expected=expected) @_mutating def put_envelope(self, document: DocumentDefinition[T], envelope: CultCacheEnvelope) -> T: @@ -562,9 +589,12 @@ class CultCache: *, batch: bool, unread: dict[str, UnreadSlots | None] | None = None, - ) -> None: + expected: _Expected | None = None, + ) -> str: """Validates every record (home and name and index extractors included) before the store is - touched; once the store accepts, applying to the cache has no fallible step.""" + touched; once the store accepts, applying to the cache has no fallible step. With `expected` (an update's commit) the write + also requires the record to be unchanged since it was read and returns 'mismatch', having written and installed nothing, + when it is not; otherwise 'committed'. A record is held under the stored_at its store reports writing.""" type = document.type # A batch naming a key twice lands the last record for it, as C# CultCacheBatch stages one operation per key. The # judge, the store and the apply all read this one list. @@ -584,13 +614,34 @@ class CultCache: ) store = self._store_for_type(type) legacy_key = self._state.legacy_global_keys.get(type) - if store is not None: - if batch: - store.push_all([envelope for envelope, _ in records]) + stamps: dict[str, Any] = {} + if expected is not None: + if legacy_key is not None: + raise self._legacy_global_refusal(document, legacy_key) + if expected.store is None: + held = self._state.envelopes.get(type, {}).get(records[0][0].key) + if store is not None or held is not expected.held: + return "mismatch" + elif store is not expected.store: + return "mismatch" else: - store.push(records[0][0]) + stamp = store.commit_if(records[0][0], expected.version) # type: ignore[attr-defined] + if stamp is None: + return "mismatch" + stamps[records[0][0].key] = stamp + elif store is not None: + if batch: + reported = store.push_all([envelope for envelope, _ in records]) + stamps = reported if isinstance(reported, dict) else {} + else: + stamps[records[0][0].key] = store.push(records[0][0]) if legacy_key is not None: self._retire_legacy_global(store, type, legacy_key) + else: + for envelope, _ in records: + held_at = next((held for held in (by_type.get(envelope.key) for by_type in self._state.envelopes.values()) if held is not None), None) + if held_at is not None: + stamps[envelope.key] = stored_at_over(envelope.stored_at, held_at.stored_at) self._state.legacy_global_keys.pop(type, None) values = self._state.values.setdefault(type, {}) envelopes = self._state.envelopes.setdefault(type, {}) @@ -598,6 +649,9 @@ class CultCache: for other, key in displaced: self._drop(other, key) for envelope, value, keys in admitted: + stamp = stamps.get(envelope.key) + if isinstance(stamp, str) and _parse_stored_at(stamp) is not None: + envelope = replace(envelope, stored_at=stamp) self._remove_lookups(type, envelope.key) values[envelope.key] = value envelopes[envelope.key] = envelope @@ -609,6 +663,7 @@ class CultCache: self._state.names[(type, keys.name)] = envelope.key for index, index_value in keys.indexes.items(): self._state.indexes[(type, index, index_value)] = envelope.key + return "committed" def _refuse_retype(self, document: DocumentDefinition[Any], written: list[CultCacheEnvelope]) -> list[tuple[str, str]]: """The one key-identity judge (variants:ruling:cache-refuses-retype): a key holds one record whatever its type, so a write @@ -663,18 +718,34 @@ class CultCache: if held is None: continue type, slots = held - raise SchemaConflictError( - self._state.envelopes[type][key].schema_id or type, - [self._state.documents[type].catalog_entry().schema_name], - key, - message=( - f"{self._state.documents[type].catalog_entry().schema_name} record {key} is read-only: {slots.type_name} was read with " - f"{slots.slot_count} slots where this build declares {slots.declared_count} ({slots.occurrences} object(s) in the " - "record). Writing it would drop data a newer writer stored; read it with types that declare those slots, or " - "write it from that build." - ), - unread_slots=slots, - ) + raise self._read_only_refusal(self._state.documents[type], key, self._state.envelopes[type][key].schema_id, slots) + + @staticmethod + def _read_only_refusal( + document: DocumentDefinition[Any], key: str, schema_id: str | None, slots: UnreadSlots + ) -> SchemaConflictError: + """The one refusal of a write to a record that carries slots this build would drop: a record the cache holds read-only, + or one an update reads from the store.""" + name = document.catalog_entry().schema_name + return SchemaConflictError( + schema_id or document.type, + [name], + key, + message=( + f"{name} record {key} is read-only: {slots.type_name} was read with " + f"{slots.slot_count} slots where this build declares {slots.declared_count} ({slots.occurrences} object(s) in the " + "record). Writing it would drop data a newer writer stored; read it with types that declare those slots, or " + "write it from that build." + ), + unread_slots=slots, + ) + + @staticmethod + def _legacy_global_refusal(document: DocumentDefinition[Any], legacy_key: str) -> CultCacheError: + return CultCacheError( + f"Global {document.type} is held under the legacy key {legacy_key!r}; update cannot copy it. " + "put_global writes the global and retires the legacy key." + ) @staticmethod def _foreign_refusal(record: ForeignRecord) -> SchemaConflictError: @@ -707,20 +778,81 @@ class CultCache: self._assert_global(document) self._put(document, GLOBAL_KEY, value) - @_mutating - def update(self, document: DocumentDefinition[T], key: str, updater: Any) -> T: - return self._update(document, key, updater) + def update( + self, document: DocumentDefinition[T], key: str, apply: Callable[[T | None], T | None], *, max_attempts: int = 5 + ) -> UpdateResult[T]: + """Reads the record at the key as its home store holds it, applies `apply` to a fresh copy of it, and commits the result + only if the record is still the one that was read; when another writer got there first it reads again and runs `apply` + again, up to `max_attempts` times. - def _update(self, document: DocumentDefinition[T], key: str, updater: Any) -> T: - current = self.get_required(document, key) - updated = updater(current) - self._put(document, key, updated) - return updated + `apply` may run up to `max_attempts` times, on a fresh copy of the record as the store holds it now (None when the key + holds nothing), which it may change and return. It must not write the same key through this cache or have side effects. + Returning None writes nothing (DECLINED). A lost race that outlasts `max_attempts` returns LOST_RACE and does not raise; + a refusal, or a raise from `apply`, propagates and is not retried. The write is durable when update returns. The + result's document is the cache's record after the outcome. `put` is the blind write. A cache with no store copies its + held instance; a store without `read` and `commit_if` (the JSON lines store among them) refuses before `apply` runs.""" + if not callable(apply): + raise TypeError("CultCache update needs a function to apply.") + if not isinstance(max_attempts, int) or isinstance(max_attempts, bool): + raise TypeError(f"CultCache update max_attempts must be an int, not {type(max_attempts).__name__}.") + if max_attempts < 1: + raise ValueError(f"CultCache update max_attempts must be at least 1, not {max_attempts}.") + for attempt in range(1, max_attempts + 1): + copy, expected = self._read_for_update(document, key) + # Outside the cache's lock and the store's: apply may take its time and may write other keys through this cache. + next_value = apply(copy) + if next_value is None: + return UpdateResult(UpdateOutcome.DECLINED, self.get(document, key)) + if self._commit_update(document, key, next_value, expected) == "committed": + return UpdateResult(UpdateOutcome.UPDATED, self.get(document, key)) + # Display only: the cache shows what the winner left. + if self._state.stores_by_type or self._state.generic_store is not None: + self.pull_all_backing_stores() + return UpdateResult(UpdateOutcome.LOST_RACE, self.get(document, key)) @_mutating - def update_global(self, document: DocumentDefinition[T], updater: Any) -> T: + def _read_for_update(self, document: DocumentDefinition[T], key: str) -> tuple[T | None, _Expected]: + """One attempt's read: the copy to hand `apply` and the condition its commit holds. Every refusal that does not depend on + what `apply` returns is raised here, so `apply` never runs for a write that cannot land. Installs nothing.""" + self._assert_registered(document) + self._refuse_at(document, key) + legacy_key = self._state.legacy_global_keys.get(document.type) + if legacy_key is not None: + raise self._legacy_global_refusal(document, legacy_key) + store = self._store_for_type(document.type) + if store is None: + held = self._state.envelopes.get(document.type, {}).get(key) + return (None if held is None else document.decode_payload(held.payload)), _Expected(held=held) + read, commit_if = getattr(store, "read", None), getattr(store, "commit_if", None) + if read is None or commit_if is None: + raise CultCacheError( + f"Backing store {type(store).__name__} for {document.type} cannot write conditionally; " + "update needs a store with read and commit_if." + ) + if document.global_document: + for envelope in store.pull_all(): + if envelope.key != GLOBAL_KEY and self._resolve_document_for_envelope(envelope) is document: + raise self._legacy_global_refusal(document, envelope.key) + found = read(key) + if found is None: + return None, _Expected(store=store) + envelope, version = found + if self._resolve_document_for_envelope(envelope) is not document: + raise CultCacheError(f"Record {key} is not a {document.type}; update cannot copy it.") + value, unread = self._decode_marked(document, envelope) + if unread is not None: + raise self._read_only_refusal(document, key, envelope.schema_id, unread) + return value, _Expected(store=store, version=version) + + @_mutating + def _commit_update(self, document: DocumentDefinition[T], key: str, value: T, expected: _Expected) -> str: + return self._put(document, key, value, expected) + + def update_global( + self, document: DocumentDefinition[T], apply: Callable[[T | None], T | None], *, max_attempts: int = 5 + ) -> UpdateResult[T]: self._assert_global(document) - return self._update(document, GLOBAL_KEY, updater) + return self.update(document, GLOBAL_KEY, apply, max_attempts=max_attempts) @_mutating def delete(self, document: DocumentDefinition[Any], key: str) -> None: diff --git a/packages/cultcache-py/src/cultcache_py/stores.py b/packages/cultcache-py/src/cultcache_py/stores.py index 894252c8..f081417c 100644 --- a/packages/cultcache-py/src/cultcache_py/stores.py +++ b/packages/cultcache-py/src/cultcache_py/stores.py @@ -1,13 +1,17 @@ from __future__ import annotations import base64 +import hashlib import json import os +import re import sys import threading import time import uuid from contextlib import contextmanager +from dataclasses import replace +from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any, Callable, Iterator, TypeVar @@ -107,14 +111,23 @@ class JsonLinesBackingStore: raise ValueError(f"Malformed CultCache JSONL envelope at {self.path}:{line_number}: missing {exc}") from exc return envelopes - def push(self, envelope: CultCacheEnvelope) -> None: - self.push_all([envelope]) + def push(self, envelope: CultCacheEnvelope) -> str | None: + return self.push_all([envelope])[envelope.key] + + def push_all(self, envelopes: list[CultCacheEnvelope], soft: bool = False) -> dict[str, str]: + """Merges the envelopes, each stored later than any record it replaces, and returns the stamps written by key + ({} when a soft push skipped).""" + stamps: dict[str, str] = {} - def push_all(self, envelopes: list[CultCacheEnvelope], soft: bool = False) -> None: def merge(existing: dict[tuple[str, str], CultCacheEnvelope]) -> None: for envelope in envelopes: + replaced = existing.get((envelope.type, envelope.key)) + if replaced is not None: + envelope = replace(envelope, stored_at=stored_at_over(envelope.stored_at, replaced.stored_at)) existing[(envelope.type, envelope.key)] = envelope + stamps[envelope.key] = envelope.stored_at self._rewrite(merge, soft) + return stamps def delete(self, type: str, key: str) -> None: self._rewrite(lambda existing: existing.pop((type, key), None)) @@ -177,23 +190,40 @@ class SingleFileMessagePackBackingStore: except Exception as exc: raise StoreUnreadableError(self.path, exc) from exc - def push(self, envelope: CultCacheEnvelope) -> None: + def push(self, envelope: CultCacheEnvelope) -> str | None: with self._write_transaction(): - self._replace_all([envelope], self.pull_all()) + return self._replace_all([envelope], self.pull_all())[envelope.key] - def push_all(self, envelopes: list[CultCacheEnvelope], soft: bool = False) -> None: + def push_all(self, envelopes: list[CultCacheEnvelope], soft: bool = False) -> dict[str, str]: """A soft push writes only where nothing is at the path: a store the reader reads is left byte for byte, and a file it - cannot read is refused by the same read, as in TS and Rust.""" + cannot read is refused by the same read, as in TS and Rust. Returns the stamps written by key ({} when skipped).""" with self._write_transaction(): arrived = self._pull() if soft and arrived is not None: - return - self._replace_all(list(envelopes), arrived or []) + return {} + return self._replace_all(list(envelopes), arrived or []) def delete(self, type: str, key: str) -> None: with self._write_transaction(): self._replace_all([], [item for item in self.pull_all() if not (item.type == type and item.key == key)]) + def read(self, key: str) -> tuple[CultCacheEnvelope, str] | None: + """The record at the key, of any type, and its content version, from one read; None when the key holds nothing.""" + for envelope in self._pull() or []: + if envelope.key == key: + return envelope, record_version(envelope) + return None + + def commit_if(self, envelope: CultCacheEnvelope, expected: str | None) -> str | None: + """Writes the envelope only if the record at its key (any type) is still the one whose version is `expected` + (None: no record), judged under the write lock; returns the stored_at written, or None with nothing written.""" + with self._write_transaction(): + arrived = self._pull() or [] + current = next((item for item in arrived if item.key == envelope.key), None) + if (None if current is None else record_version(current)) != expected: + return None + return self._replace_all([envelope], arrived)[envelope.key] + @contextmanager def _write_transaction(self) -> Iterator[None]: """The thread lock, then the cross-process lock on `.lock`, held across the read and the replace so two @@ -203,8 +233,10 @@ class SingleFileMessagePackBackingStore: with store_write_lock(self.path, self.lock_timeout): yield - def _replace_all(self, supplied: list[CultCacheEnvelope], arrived: list[CultCacheEnvelope]) -> None: - commit_store_file(self.path, msgpack.packb(_encode_snapshot(supplied, arrived, self._format), use_bin_type=True)) + def _replace_all(self, supplied: list[CultCacheEnvelope], arrived: list[CultCacheEnvelope]) -> dict[str, str]: + snapshot, stamps = _encode_snapshot(supplied, arrived, self._format) + commit_store_file(self.path, msgpack.packb(snapshot, use_bin_type=True)) + return stamps @@ -305,7 +337,7 @@ def written_owns(held: CultCacheEnvelope, written: CultCacheEnvelope) -> bool: def _encode_snapshot( supplied: list[CultCacheEnvelope], arrived: list[CultCacheEnvelope], format_version: str -) -> list[Any]: +) -> tuple[list[Any], dict[str, str]]: # A key names one record whatever its type, so a supplied record replaces the arrived one by key alone, and only of the # type that owns it: retyping a key is a removal and an add, so a record another type owns under the key refuses the write. held_by_key = {envelope.key: envelope for envelope in arrived} @@ -314,8 +346,12 @@ def _encode_snapshot( for held in (landing.get(envelope.key), held_by_key.get(envelope.key)): if held is not None and not written_owns(held, envelope): raise retyped(held, envelope) + held = held_by_key.get(envelope.key) + if held is not None: + envelope = replace(envelope, stored_at=stored_at_over(envelope.stored_at, held.stored_at)) landing[envelope.key] = envelope supplied = list(landing.values()) + stamps = {envelope.key: envelope.stored_at for envelope in supplied} replaced = set(landing) kept = [envelope for envelope in arrived if envelope.key not in replaced] envelopes = [*kept, *supplied] @@ -330,7 +366,7 @@ def _encode_snapshot( [envelope.key, _schema_id_for(envelope), envelope.stored_at, envelope.payload] for envelope in sorted(envelopes, key=lambda item: item.key) ] - return [format_version, catalog, records] + return [format_version, catalog, records], stamps def commit_store_file(path: Path, data: bytes) -> None: """Writes `data` to a unique staged file beside the store, fsyncs it, replaces the store with it and, off Windows, @@ -739,6 +775,65 @@ def _schema_id_for(envelope: CultCacheEnvelope) -> str: return envelope.schema_id or envelope.type +_STORED_AT_SHAPE = re.compile( + r"([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})(?:\.([0-9]{1,9}))?(?:(Z)|([+-])([01][0-9]|2[0-3]):([0-5][0-9]))" +) + + +def _parse_stored_at(text: Any) -> datetime | None: + """The UTC instant a stored_at text names, or None. Only the shape the runtimes write is a stamp: an RFC 3339 date-time with T, + an optional fraction of 1 to 9 digits (cut to the microsecond) and Z or a numeric offset of hours 00-23 and minutes 00-59. Checked here, not left to + fromisoformat, whose accepted set differs by interpreter (3.10 and 3.13) and from Rust and TypeScript. An instant outside + datetime's range is not a stamp.""" + if not isinstance(text, str): + return None + shape = _STORED_AT_SHAPE.fullmatch(text) + if shape is None: + return None + year, month, day, hour, minute, second, fraction, zulu, sign, offset_hours, offset_minutes = shape.groups() + try: + offset = timezone.utc if zulu else timezone( + (-1 if sign == "-" else 1) * timedelta(hours=int(offset_hours), minutes=int(offset_minutes)) + ) + local = datetime( + int(year), int(month), int(day), int(hour), int(minute), int(second), int(((fraction or "") + "000000")[:6]), offset + ) + return local.astimezone(timezone.utc) + except (ValueError, OverflowError): + return None + + +def mint_stored_at(previous: str | None, now: datetime) -> str: + """A stored_at later than `previous` (C# MintStoredAt): `now`, or the microsecond after `previous` when `now` is not later. + A previous that does not parse leaves `now`. Raises ValueError, nothing minted, when `previous` is the last instant a stamp + can name, so a write over it is refused and writes nothing.""" + now = now.astimezone(timezone.utc) + last = _parse_stored_at(previous) + if last is not None and now <= last: + try: + now = last + timedelta(microseconds=1) + except OverflowError: + raise ValueError(f"stored_at {previous!r} is the last instant a stamp can name; a write over it cannot be stored later") from None + return now.isoformat(timespec="microseconds") + + +def stored_at_over(staged: str, replaced: str) -> str: + """The stored_at a record replacing `replaced` is written with (C# StoredAtOver): `staged` when both parse and it is strictly + later, else one minted over `replaced`. No content is compared: every replacing write is stamped later.""" + later, earlier = _parse_stored_at(staged), _parse_stored_at(replaced) + if later is not None and earlier is not None and later > earlier: + return staged + return mint_stored_at(replaced, datetime.now(timezone.utc)) + + +def record_version(envelope: CultCacheEnvelope) -> str: + """The content version of a stored record: the lowercase hex SHA-256 of its persisted encoding, the array + [key, schema id, stored_at, payload] exactly as the single-file store writes it + (variants:ruling:write-set-content-version-declared-ownership).""" + record = [envelope.key, _schema_id_for(envelope), envelope.stored_at, envelope.payload] + return hashlib.sha256(msgpack.packb(record, use_bin_type=True)).hexdigest() + + def _default_catalog_entry(envelope: CultCacheEnvelope) -> CultCacheSchemaCatalogEntry: schema_id = _schema_id_for(envelope) return CultCacheSchemaCatalogEntry( diff --git a/packages/cultcache-py/tests/test_content_version_vector.py b/packages/cultcache-py/tests/test_content_version_vector.py new file mode 100644 index 00000000..393784e0 --- /dev/null +++ b/packages/cultcache-py/tests/test_content_version_vector.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import shutil +import tempfile +import unittest +from pathlib import Path + +import msgpack + +from cultcache_py import SingleFileMessagePackBackingStore +from cultcache_py.stores import record_version + +# variants:ruling:write-set-content-version-declared-ownership. The vector in tests/vectors/content-version is written only by +# the C# reference; every row of it is computed here from the store file, by the store's read and by record_version. +VECTORS = Path(__file__).resolve().parents[3] / "tests" / "vectors" / "content-version" + + +class ContentVersionVectorTests(unittest.TestCase): + def test_every_row_of_the_vector_equals_this_runtimes_version(self) -> None: + rows = msgpack.unpackb((VECTORS / "versions.msgpack").read_bytes(), raw=False) + self.assertEqual([key for key, _ in rows], ["alpha", "beta"]) + directory = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, directory, ignore_errors=True) + copy = directory / "store.cc" + shutil.copyfile(VECTORS / "store.cc", copy) + store = SingleFileMessagePackBackingStore(copy) + by_key = {envelope.key: envelope for envelope in store.pull_all()} + for key, version in rows: + with self.subTest(key=key): + found = store.read(key) + self.assertIsNotNone(found) + self.assertEqual(found[1], version) + self.assertEqual(record_version(by_key[key]), version) + self.assertIsNone(store.read("gamma")) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/cultcache-py/tests/test_cultcache.py b/packages/cultcache-py/tests/test_cultcache.py index 6b95f638..7ff4e297 100644 --- a/packages/cultcache-py/tests/test_cultcache.py +++ b/packages/cultcache-py/tests/test_cultcache.py @@ -86,7 +86,7 @@ class CultCacheTests(unittest.TestCase): ) cache.pull_all_backing_stores() cache.put(document, "item:potion", Item(name="Potion", category="Consumable", value=50)) - cache.update(document, "item:potion", lambda item: Item(item.name, item.category, item.value + 5)) + cache.put(document, "item:potion", Item(name="Potion", category="Consumable", value=55)) self.assertEqual(cache.get_required(document, "item:potion").value, 55) cache.delete(document, "item:potion") self.assertIsNone(cache.get(document, "item:potion")) diff --git a/packages/cultcache-py/tests/test_first_call.py b/packages/cultcache-py/tests/test_first_call.py index 10d6eb27..232f6c7d 100644 --- a/packages/cultcache-py/tests/test_first_call.py +++ b/packages/cultcache-py/tests/test_first_call.py @@ -4,6 +4,8 @@ import unittest from pathlib import Path +from cultcache_py import UpdateOutcome + # snippet:begin import tempfile from dataclasses import asdict, dataclass @@ -51,15 +53,44 @@ def first_call() -> Item: # snippet:end +# snippet:update:begin +def restock(path: Path): + cache = ( + CultCache.builder() + .register_document_type(item_doc) + .add_generic_store(SingleFileMessagePackBackingStore(path)) + .build() + ) + cache.pull_all_backing_stores() + return cache.update(item_doc, "item:potion", lambda item: Item("Potion", "Consumable", (item.value if item else 0) + 10)) +# snippet:update:end + + class FirstCallTests(unittest.TestCase): def test_first_call_round_trips_through_a_second_cache(self) -> None: self.assertEqual(first_call(), Item("Potion", "Consumable", 50)) - def test_docs_show_the_tested_snippet(self) -> None: + def test_update_with_retry_through_a_second_cache(self) -> None: + path = Path(tempfile.mkdtemp()) / "restock.cc" + restock(path) + result = restock(path) + self.assertIs(result.outcome, UpdateOutcome.UPDATED) + reopened = ( + CultCache.builder() + .register_document_type(item_doc) + .add_generic_store(SingleFileMessagePackBackingStore(path)) + .build() + ) + reopened.pull_all_backing_stores() + self.assertEqual(reopened.get_required(item_doc, "item:potion"), Item("Potion", "Consumable", 20)) + + def test_docs_show_the_tested_snippets(self) -> None: source = Path(__file__).read_text(encoding="utf-8") - snippet = source.split("# snippet:begin\n", 1)[1].split("# snippet:end\n", 1)[0] docs = (Path(__file__).parents[3] / "docs" / "start" / "README.md").read_text(encoding="utf-8") - self.assertIn("```python\n" + snippet + "```", docs) + for region in ("snippet", "snippet:update"): + with self.subTest(region=region): + snippet = source.split(f"# {region}:begin\n", 1)[1].split(f"# {region}:end\n", 1)[0] + self.assertIn("```python\n" + snippet + "```", docs) if __name__ == "__main__": diff --git a/packages/cultcache-py/tests/test_unique_index.py b/packages/cultcache-py/tests/test_unique_index.py index accc27dd..6e831b9a 100644 --- a/packages/cultcache-py/tests/test_unique_index.py +++ b/packages/cultcache-py/tests/test_unique_index.py @@ -148,7 +148,6 @@ class UniqueIndexTests(unittest.TestCase): "put": lambda cache, d, k, i: cache.put(d, k, i), "put_envelope": lambda cache, d, k, i: cache.put_envelope(d, envelope(d, k, i)), "put_envelopes": lambda cache, d, k, i: cache.put_envelopes(d, [envelope(d, k, i)]), - "update": lambda cache, d, k, i: cache.update(d, k, lambda _held: i), } for name, write in paths.items(): with self.subTest(alias=alias, path=name): diff --git a/packages/cultcache-py/tests/test_update.py b/packages/cultcache-py/tests/test_update.py new file mode 100644 index 00000000..34f030a8 --- /dev/null +++ b/packages/cultcache-py/tests/test_update.py @@ -0,0 +1,731 @@ +"""CultCache.update (variants:cut_spec:cut-update-with-retry-py): read the record as its home store holds it, apply a function, +commit only if the record is unchanged, run the function again on a lost race. Mirrors C# UpdateTests and the TS update tests.""" + +from __future__ import annotations + +import shutil +import tempfile +import threading +import time +import unittest +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from pathlib import Path + +from cultcache_py import ( + CultCache, + CultCacheEnvelope, + JsonLinesBackingStore, + SchemaConflictError, + SingleFileMessagePackBackingStore, + UpdateOutcome, + UpdateResult, + database_entry_field, + define_database_entry_type, + define_document_type, + leaves as t, +) +from cultcache_py.cache import GLOBAL_KEY, CultCacheError +from cultcache_py.stores import _parse_stored_at, mint_stored_at, record_version, stored_at_over + + +@dataclass +class Item: + name: str = "n" + value: int = 0 + sku: str | None = None + + +def _doc(type: str, **extra): + return define_document_type(type, encode=asdict, decode=lambda raw: Item(**raw), **extra) + + +item_doc = _doc("item") +other_doc = _doc("other") +sku_doc = _doc("skuitem", indexes={"sku": "sku"}) +global_doc = _doc("gitem", global_document=True) + +@dataclass +class Wide: + name: str = "" + extra: str = "" + + +@dataclass +class Narrow: + name: str = "" + + +ALPHA = "2099-01-01T00:00:00.0000000Z" + + +class Fixture(unittest.TestCase): + def setUp(self) -> None: + self.dir = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True) + self.path = self.dir / "state.cc" + + def cache(self, *documents, path: Path | None = None, store=True) -> CultCache: + builder = CultCache.builder() + for document in documents or (item_doc,): + builder.register_document_type(document) + if store: + builder.add_generic_store(SingleFileMessagePackBackingStore(path or self.path)) + cache = builder.build() + if store: + cache.pull_all_backing_stores() + return cache + + def stored(self, key: str = "k", document=item_doc): + """What a fresh cache reads at the key.""" + return self.cache(document).get(document, key) + + def bytes(self) -> bytes | None: + return self.path.read_bytes() if self.path.exists() else None + + +class Counting: + """An apply that records what it was given.""" + + def __init__(self, fn=None) -> None: + self.seen: list = [] + self.fn = fn or (lambda item, calls: Item("n", (item.value if item else 0) + 1)) + + def __call__(self, item): + self.seen.append(None if item is None else item.value) + # An always-losing apply must fail by assertion when the attempt bound is gone, not hang. + assert len(self.seen) <= 10, "apply ran past the attempt bound" + return self.fn(item, len(self.seen)) + + @property + def calls(self) -> int: + return len(self.seen) + + +class UpdateTests(Fixture): + def test_two_writers_updating_lose_no_increment(self) -> None: + results: list[UpdateResult] = [] + calls = [0] + lock = threading.Lock() + a, b = self.cache(), self.cache() + a.put(item_doc, "k", Item("n", 0)) + + def bump(item): + with lock: + calls[0] += 1 + time.sleep(0) + return Item("n", item.value + 1) + + def run(cache) -> None: + for _ in range(50): + results.append(cache.update(item_doc, "k", bump, max_attempts=200)) + + threads = [threading.Thread(target=run, args=(cache,)) for cache in (a, b)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + self.assertTrue(all(result.outcome is UpdateOutcome.UPDATED for result in results)) + self.assertEqual(self.stored().value, 100) + self.assertGreater(calls[0], 100) + + def test_a_lost_attempt_reruns_apply_on_the_fresh_value(self) -> None: + cache, rival = self.cache(), self.cache() + cache.put(item_doc, "k", Item("n", 1)) + rival.pull_all_backing_stores() + apply = Counting(lambda item, calls: (rival.put(item_doc, "k", Item("n", 100)) if calls == 1 else None) or Item("n", item.value + 1)) + result = cache.update(item_doc, "k", apply) + self.assertEqual(apply.seen, [1, 100]) + self.assertIs(result.outcome, UpdateOutcome.UPDATED) + self.assertEqual(result.document.value, 101) + self.assertEqual(self.stored().value, 101) + + def test_exhausted_attempts_return_lost_race(self) -> None: + cache, rival = self.cache(), self.cache() + cache.put(item_doc, "k", Item("n", 1)) + rival.pull_all_backing_stores() + apply = Counting(lambda item, calls: rival.put(item_doc, "k", Item("n", 100 * calls)) or Item("n", -1)) + result = cache.update(item_doc, "k", apply, max_attempts=3) + self.assertEqual(apply.calls, 3) + self.assertIs(result.outcome, UpdateOutcome.LOST_RACE) + self.assertEqual(result.document.value, 300) # the pull before LOST_RACE shows the winner + self.assertEqual(self.stored().value, 300) + + def test_max_attempts_bounds(self) -> None: + cache, rival = self.cache(), self.cache() + cache.put(item_doc, "k", Item("n", 1)) + rival.pull_all_backing_stores() + + def always_losing(item, calls): + rival.put(item_doc, "k", Item("n", 100 + calls)) + return Item("n", -1) + + apply = Counting(always_losing) + result = cache.update(item_doc, "k", apply, max_attempts=1) + self.assertEqual((apply.calls, result.outcome), (1, UpdateOutcome.LOST_RACE)) + for bad, error in ((0, ValueError), (-1, ValueError), (True, TypeError), (1.5, TypeError), (None, TypeError)): + with self.subTest(max_attempts=bad): + untouched = Counting() + with self.assertRaises(error): + cache.update(item_doc, "k", untouched, max_attempts=bad) + self.assertEqual(untouched.calls, 0) + with self.assertRaises(TypeError): + cache.update(item_doc, "k", "not callable") + with self.assertRaises(TypeError): + cache.update(item_doc, "k", None) + + def test_apply_gets_a_fresh_copy(self) -> None: + cache, rival = self.cache(), self.cache() + cache.put(item_doc, "k", Item("n", 1)) + rival.pull_all_backing_stores() + arguments: list[Item] = [] + + def apply(item): + arguments.append(item) + self.assertIsNot(item, cache.get(item_doc, "k")) + if len(arguments) == 1: + item.value = 777 # changing the copy in place changes nothing the cache or the next attempt sees + rival.put(item_doc, "k", Item("n", 5)) + self.assertEqual(cache.get(item_doc, "k").value, 1) + return Item("n", item.value + 1) + + result = cache.update(item_doc, "k", apply) + self.assertEqual([item.value for item in arguments], [777, 5]) + self.assertIsNot(arguments[0], arguments[1]) + self.assertEqual(result.document.value, 6) + + def test_an_in_place_change_to_the_held_value_is_neither_applied_nor_persisted(self) -> None: + cache = self.cache() + cache.put(item_doc, "k", Item("n", 1)) + cache.get(item_doc, "k").value = 999 + apply = Counting(lambda item, calls: Item("n", item.value + 1)) + result = cache.update(item_doc, "k", apply) + self.assertEqual(apply.seen, [1]) + self.assertEqual(self.stored().value, 2) + result.document.value = 999 + cache.update(item_doc, "k", apply) + self.assertEqual(apply.seen, [1, 2]) + self.assertEqual(self.stored().value, 3) + + bare = self.cache(store=False) # a cache with no store copies its held instance + bare.put(item_doc, "k", Item("n", 1)) + bare.get(item_doc, "k").value = 999 + seen = Counting(lambda item, calls: Item("n", item.value + 1)) + self.assertEqual(bare.update(item_doc, "k", seen).document.value, 2) + self.assertEqual(seen.seen, [1]) + + def test_none_declines(self) -> None: + cache = self.cache() + cache.put(item_doc, "k", Item("n", 1)) + before = self.bytes() + result = cache.update(item_doc, "k", lambda item: None) + self.assertIs(result.outcome, UpdateOutcome.DECLINED) + self.assertEqual(result.document, cache.get(item_doc, "k")) + self.assertEqual(self.bytes(), before) + absent = cache.update(item_doc, "missing", lambda item: None) + self.assertEqual((absent.outcome, absent.document), (UpdateOutcome.DECLINED, None)) + self.assertEqual(self.bytes(), before) + empty = Fixture.cache(self, path=self.dir / "empty.cc") + self.assertEqual(empty.update(item_doc, "k", lambda item: None).outcome, UpdateOutcome.DECLINED) + self.assertFalse((self.dir / "empty.cc").exists()) + + def test_a_declined_or_lost_attempt_installs_nothing(self) -> None: + cache, rival = self.cache(), self.cache() + cache.put(item_doc, "k", Item("n", 1)) + rival.pull_all_backing_stores() + rival.put(item_doc, "k", Item("n", 5)) + self.assertIs(cache.update(item_doc, "k", lambda item: None).outcome, UpdateOutcome.DECLINED) + self.assertEqual(cache.get(item_doc, "k").value, 1) + cache.pull_all_backing_stores() + self.assertEqual(cache.get(item_doc, "k").value, 5) + + during: list[int] = [] + + def apply(item, calls): + during.append(cache.get(item_doc, "k").value) + if calls == 1: + rival.put(item_doc, "k", Item("n", 9)) + return Item("n", item.value + 1) + + cache.update(item_doc, "k", Counting(apply)) + self.assertEqual(during, [5, 5]) # the lost attempt published nothing mid-update + + def test_absent_key_rival_removal_and_a_store_that_holds_nothing(self) -> None: + a, b = self.cache(), self.cache() + bump = lambda item: Item("n", (item.value if item else 0) + 1) # noqa: E731 + self.assertIs(a.update(item_doc, "k", bump).outcome, UpdateOutcome.UPDATED) + self.assertIs(b.update(item_doc, "k", bump).outcome, UpdateOutcome.UPDATED) + self.assertEqual(self.stored().value, 2) + + cache, rival = self.cache(), self.cache() + cache.put(item_doc, "d", Item("n", 1)) + rival.pull_all_backing_stores() + apply = Counting(lambda item, calls: (rival.delete(item_doc, "d") if calls == 1 else None) or Item("n", (item.value if item else 0) + 1)) + cache.update(item_doc, "d", apply) + self.assertEqual(apply.seen, [1, None]) + self.assertEqual(self.stored("d").value, 1) + + holder = self.cache() + holder.put(item_doc, "h", Item("n", 3)) + rival.pull_all_backing_stores() + rival.delete(item_doc, "h") + nothing = Counting(lambda item, calls: Item("n", 1)) + holder.update(item_doc, "h", nothing) + self.assertEqual(nothing.seen, [None]) + + def test_a_rival_writing_and_restoring_the_value_still_reruns_apply(self) -> None: + cache, rival = self.cache(), self.cache() + cache.put(item_doc, "k", Item("n", 1)) + rival.pull_all_backing_stores() + + def apply(item, calls): + if calls == 1: + rival.put(item_doc, "k", Item("n", 5)) + rival.put(item_doc, "k", Item("n", 1)) + return Item("n", item.value + 1) + + counting = Counting(apply) + cache.update(item_doc, "k", counting) + self.assertEqual(counting.seen, [1, 1]) + self.assertEqual(self.stored().value, 2) + + def test_a_stale_caches_first_attempt_reads_the_store(self) -> None: + a, b = self.cache(), self.cache() + a.put(item_doc, "k", Item("n", 1)) + b.pull_all_backing_stores() + a.put(item_doc, "k", Item("n", 7)) + apply = Counting(lambda item, calls: Item("n", item.value + 1)) + b.update(item_doc, "k", apply) + self.assertEqual(apply.seen, [7]) + self.assertEqual(self.stored().value, 8) + + +class UpdateRefusalTests(Fixture): + def refused(self, cache: CultCache, key: str = "k", document=item_doc, *errors) -> None: + before = self.bytes() + apply = Counting() + with self.assertRaises(errors or (CultCacheError, SchemaConflictError)): + cache.update(document, key, apply) + self.assertEqual(apply.calls, 0) + self.assertEqual(self.bytes(), before) + + def test_refusals_before_apply(self) -> None: + writer = self.cache(item_doc, other_doc) + writer.put(other_doc, "k", Item("n", 1)) + self.refused(writer) # the record at the key is another document's, held + self.refused(self.fresh(item_doc, other_doc)) # unpulled: the store's record is another document's + self.refused(self.cache(item_doc)) # foreign: the store holds a type this cache never registered + self.refused(self.fresh(item_doc)) + storeless = self.cache(item_doc, other_doc, store=False) + storeless.put(other_doc, "k", Item("n", 1)) + apply = Counting() + with self.assertRaises((CultCacheError, SchemaConflictError)): + storeless.update(item_doc, "k", apply) + self.assertEqual(apply.calls, 0) + + def fresh(self, *documents) -> CultCache: + """A cache that has not pulled: whatever it reads, it reads from the store at update.""" + builder = CultCache.builder() + for document in documents: + builder.register_document_type(document) + return builder.add_generic_store(SingleFileMessagePackBackingStore(self.path)).build() + + def test_a_home_without_read_and_commit_if_refuses(self) -> None: + jsonl = JsonLinesBackingStore(self.dir / "state.jsonl") + cache = CultCache.builder().register_document_type(item_doc).add_generic_store(jsonl).build() + cache.pull_all_backing_stores() + cache.put(item_doc, "k", Item("n", 1)) + before = (self.dir / "state.jsonl").read_bytes() + apply = Counting() + with self.assertRaisesRegex(CultCacheError, "cannot write conditionally"): + cache.update(item_doc, "k", apply) + self.assertEqual(apply.calls, 0) + self.assertEqual((self.dir / "state.jsonl").read_bytes(), before) + self.assertFalse(hasattr(JsonLinesBackingStore, "read") or hasattr(JsonLinesBackingStore, "commit_if")) + + class Bare: + def pull_all(self): + return [] + + def push(self, envelope): + raise AssertionError("update wrote through a store that cannot write conditionally") + + def push_all(self, envelopes, soft=False): + raise AssertionError("push_all") + + def delete(self, type, key): + raise AssertionError("delete") + + bare = CultCache.builder().register_document_type(item_doc).add_generic_store(Bare()).build() + with self.assertRaisesRegex(CultCacheError, "Bare"): + bare.update(item_doc, "k", apply) + self.assertEqual(apply.calls, 0) + + def test_read_only_records_refuse_before_apply(self) -> None: + wide = define_database_entry_type( + "tests.rw", + [database_entry_field("name", 0, codec=t.string), database_entry_field("extra", 1, codec=t.string)], + cls=Wide, + schema_id="tests.rw.v1", + ) + narrow = define_database_entry_type("tests.rw", [database_entry_field("name", 0, codec=t.string)], cls=Narrow, schema_id="tests.rw.v1") + self.cache(wide).put(wide, "k", Wide("n", "x")) + before = self.bytes() + for pulled in (True, False): # held read-only, then a store record the cache never loaded + with self.subTest(pulled=pulled): + cache = self.cache(narrow) if pulled else self.fresh(narrow) + apply = Counting() + with self.assertRaises(SchemaConflictError) as caught: + cache.update(narrow, "k", apply) + self.assertEqual(caught.exception.record_key, "k") + self.assertEqual(apply.calls, 0) + self.assertEqual(self.bytes(), before) + + def test_a_non_global_key_for_a_global_document_refuses(self) -> None: + cache = self.cache(global_doc) + apply = Counting() + with self.assertRaises(CultCacheError): + cache.update(global_doc, "not-global", apply) + self.assertEqual(apply.calls, 0) + + def test_refusals_after_apply_are_not_retried(self) -> None: + cache = self.cache() + cache.put(item_doc, "k", Item("n", 1)) + before = self.bytes() + boom = Counting(lambda item, calls: (_ for _ in ()).throw(RuntimeError("boom"))) + with self.assertRaisesRegex(RuntimeError, "boom"): + cache.update(item_doc, "k", boom) + self.assertEqual(boom.calls, 1) + self.assertEqual(self.bytes(), before) + + indexed = self.cache(sku_doc, path=self.dir / "sku.cc") + indexed.put(sku_doc, "a", Item("n", 1, "X")) + indexed.put(sku_doc, "b", Item("n", 1, "Y")) + before = (self.dir / "sku.cc").read_bytes() + clash = Counting(lambda item, calls: Item("n", 2, "X")) + with self.assertRaisesRegex(CultCacheError, "unique index"): + indexed.update(sku_doc, "b", clash) + self.assertEqual(clash.calls, 1) + self.assertEqual((self.dir / "sku.cc").read_bytes(), before) + + +class UpdateOwnWritesTests(Fixture): + def test_own_writes_and_the_held_instance(self) -> None: + cache = self.cache() + cache.put(item_doc, "k", Item("n", 1)) + apply = Counting(lambda item, calls: Item("n", item.value + 1)) + cache.update(item_doc, "k", apply) + second = cache.update(item_doc, "k", apply) + self.assertEqual(apply.calls, 2) + self.assertIs(second.document, cache.get(item_doc, "k")) + self.assertEqual(second.document.value, 3) + + def sideways(item, calls): + cache.put(item_doc, "side", Item("n", 5)) # apply runs outside the cache's hold + return Item("n", item.value + 1) + + sideways_apply = Counting(sideways) + result = cache.update(item_doc, "k", sideways_apply) + self.assertEqual((result.outcome, sideways_apply.calls), (UpdateOutcome.UPDATED, 1)) + self.assertEqual(self.stored("side").value, 5) + + def test_store_less_cache(self) -> None: + cache = self.cache(store=False) + cache.put(item_doc, "k", Item("n", 0)) + results: list[UpdateResult] = [] + + def bump(item): + time.sleep(0) + return Item("n", item.value + 1) + + def run() -> None: + for _ in range(50): + results.append(cache.update(item_doc, "k", bump, max_attempts=500)) + + threads = [threading.Thread(target=run) for _ in range(2)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + self.assertTrue(all(result.outcome is UpdateOutcome.UPDATED for result in results)) + self.assertEqual(cache.get(item_doc, "k").value, 100) + + # A store that becomes the type's home while apply runs holds a record the cache never read: the commit loses. + cache = self.cache(store=False) # holds no record of the type, so a store may become its home + other = self.cache(path=self.dir / "late.cc") + other.put(item_doc, "fresh", Item("n", 40)) + late = SingleFileMessagePackBackingStore(self.dir / "late.cc") + + def attach(item, calls): + if calls == 1: + cache.add_generic_store(late) + return Item("n", (item.value if item else 0) + 1) + + apply = Counting(attach) + result = cache.update(item_doc, "fresh", apply) + self.assertEqual(apply.seen, [None, 40]) + self.assertIs(result.outcome, UpdateOutcome.UPDATED) + self.assertEqual(self.cache(path=self.dir / "late.cc").get(item_doc, "fresh").value, 41) + + def test_a_home_attached_while_apply_runs_is_reread_or_refused(self) -> None: + """The cache's home for the type changes between the read and the commit (attaching is allowed while it holds no record + of the type): the version read from the old home is no condition on the new one.""" + cache = self.cache(path=self.dir / "generic.cc") + late = SingleFileMessagePackBackingStore(self.dir / "late.cc") + + def attach(item, calls): + if calls == 1: + cache.add_backing_store(late, ["item"]) + return Item("n", 1) + + apply = Counting(attach) + self.assertIs(cache.update(item_doc, "k", apply).outcome, UpdateOutcome.UPDATED) + self.assertEqual(apply.seen, [None, None]) # reread from the new home, not committed with the old home's version + self.assertEqual(self.cache(path=self.dir / "late.cc").get(item_doc, "k").value, 1) + + cache = self.cache(path=self.dir / "generic2.cc") + jsonl = JsonLinesBackingStore(self.dir / "late.jsonl") + + def attach_jsonl(item, calls): + cache.add_backing_store(jsonl, ["item"]) + return Item("n", 1) + + apply = Counting(attach_jsonl) + with self.assertRaisesRegex(CultCacheError, "cannot write conditionally"): + cache.update(item_doc, "k", apply) + self.assertEqual(apply.calls, 1) + self.assertFalse((self.dir / "late.jsonl").exists()) + + def test_ledger_under_contention(self) -> None: + caches = [self.cache() for _ in range(3)] + outcomes: list[UpdateOutcome] = [] + + def run(cache) -> None: + for _ in range(25): + outcomes.append(cache.update(item_doc, "k", lambda item: Item("n", (item.value if item else 0) + 1)).outcome) + + threads = [threading.Thread(target=run, args=(cache,)) for cache in caches] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + self.assertEqual(self.stored().value, outcomes.count(UpdateOutcome.UPDATED)) + self.assertEqual(len(outcomes), 75) + + +class UpdateLegacyGlobalTests(Fixture): + def plant(self, key: str = "legacy") -> None: + entry = global_doc.catalog_entry() + SingleFileMessagePackBackingStore(self.path).push( + CultCacheEnvelope.create(key=key, type=global_doc.type, payload=global_doc.encode_payload(Item("n", 1)), schema_id=entry.schema_id, catalog_entry=entry) + ) + + def test_update_global_updates_the_global_record(self) -> None: + cache = self.cache(global_doc) + result = cache.update_global(global_doc, lambda item: Item("n", (item.value if item else 0) + 1)) + self.assertIs(result.outcome, UpdateOutcome.UPDATED) + self.assertEqual(self.cache(global_doc).get(global_doc, GLOBAL_KEY).value, 1) + + def test_a_legacy_global_is_refused_until_put_global_retires_it(self) -> None: + self.plant() + cache = self.cache(global_doc) # loaded from the legacy key + before = self.bytes() + for call in (lambda a: cache.update_global(global_doc, a), lambda a: cache.update(global_doc, GLOBAL_KEY, a)): + apply = Counting() + with self.assertRaisesRegex(CultCacheError, "put_global"): + call(apply) + self.assertEqual(apply.calls, 0) + self.assertEqual(self.bytes(), before) + # Judged from the cache's load, not only from the store: the legacy record is gone from the store, the cache still holds it. + SingleFileMessagePackBackingStore(self.path).delete(global_doc.type, "legacy") + apply = Counting() + with self.assertRaisesRegex(CultCacheError, "put_global"): + cache.update_global(global_doc, apply) + self.assertEqual(apply.calls, 0) + cache.put_global(global_doc, Item("n", 5)) + self.assertEqual(cache.update_global(global_doc, lambda item: Item("n", item.value + 1)).document.value, 6) + + def test_a_pull_inside_apply_that_loads_a_legacy_global_fails_the_commit(self) -> None: + cache = self.cache(global_doc) + + def apply(item): + self.plant() + cache.pull_all_backing_stores() + return Item("n", 9) + + with self.assertRaisesRegex(CultCacheError, "put_global"): + cache.update_global(global_doc, apply) + self.assertEqual([envelope.key for envelope in SingleFileMessagePackBackingStore(self.path).pull_all()], ["legacy"]) + + def test_an_unpulled_cache_judges_the_legacy_global_from_the_store(self) -> None: + self.plant() + before = self.bytes() + cache = SingleFileMessagePackBackingStore(self.path) + unpulled = CultCache.builder().register_document_type(global_doc).add_generic_store(cache).build() + apply = Counting() + with self.assertRaisesRegex(CultCacheError, "put_global"): + unpulled.update_global(global_doc, apply) + self.assertEqual(apply.calls, 0) + self.assertEqual(self.bytes(), before) + + +class StoreRestampTests(Fixture): + def envelope(self, value: int, stored_at: str = ALPHA, key: str = "k") -> CultCacheEnvelope: + entry = item_doc.catalog_entry() + return CultCacheEnvelope(key, "item", item_doc.encode_payload(Item("n", value)), stored_at, entry.schema_id, entry) + + def stamp_on_disk(self, store, key: str = "k") -> str: + return next(envelope.stored_at for envelope in store.pull_all() if envelope.key == key) + + def later_than_alpha(self, text: str) -> bool: + return _parse_stored_at(text) > _parse_stored_at(ALPHA) + + def test_commit_if_and_the_restamp_at_the_store(self) -> None: + store = SingleFileMessagePackBackingStore(self.path) + self.assertIsNone(store.read("k")) + stamp = store.commit_if(self.envelope(1), None) # None against an absent key commits + self.assertEqual(stamp, ALPHA) + envelope, version = store.read("k") + self.assertEqual(version, record_version(envelope)) + before = self.bytes() + self.assertIsNone(store.commit_if(self.envelope(2, "2100-01-01T00:00:00Z"), None)) # a record is there: not absent + self.assertIsNone(store.commit_if(self.envelope(2, "2100-01-01T00:00:00Z"), "0" * 64)) + self.assertEqual(self.bytes(), before) + written = store.commit_if(self.envelope(2), version) # a staged stamp that is no later is minted over + self.assertTrue(self.later_than_alpha(written)) + self.assertEqual(self.stamp_on_disk(store), written) + self.assertEqual(stamp_of(store, "k"), written) + + def test_every_replacing_write_is_stored_strictly_later(self) -> None: + for make in (SingleFileMessagePackBackingStore, JsonLinesBackingStore): + for name in ("push", "push_all", "commit_if"): + if name == "commit_if" and make is JsonLinesBackingStore: + continue + with self.subTest(store=make.__name__, write=name): + path = self.dir / f"{make.__name__}-{name}" + store = make(path) + store.push(self.envelope(1)) + if name == "push": + reported = store.push(self.envelope(2)) + elif name == "push_all": + reported = store.push_all([self.envelope(2)])["k"] + else: + reported = store.commit_if(self.envelope(2), store.read("k")[1]) + self.assertTrue(self.later_than_alpha(reported)) + self.assertEqual(self.stamp_on_disk(store), reported) + + def test_the_cache_holds_the_stamp_the_store_wrote(self) -> None: + for name in ("put", "put_envelope", "put_envelopes", "update"): + with self.subTest(write=name): + path = self.dir / name + SingleFileMessagePackBackingStore(path).push(self.envelope(1)) + cache = self.cache(path=path) + self.assertEqual(cache.get_envelope(item_doc, "k").stored_at, ALPHA) + if name == "put": + cache.put(item_doc, "k", Item("n", 2)) + elif name == "put_envelope": + cache.put_envelope(item_doc, self.envelope(2)) + elif name == "put_envelopes": + cache.put_envelopes(item_doc, [self.envelope(2)]) + else: + cache.update(item_doc, "k", lambda item: Item("n", 2)) + held = cache.get_envelope(item_doc, "k").stored_at + self.assertTrue(self.later_than_alpha(held)) + self.assertEqual(held, self.stamp_on_disk(SingleFileMessagePackBackingStore(path))) + + def test_a_store_less_cache_stamps_a_replacing_write_later(self) -> None: + cache = self.cache(store=False) + cache.put_envelope(item_doc, self.envelope(1)) + cache.put_envelope(item_doc, self.envelope(2, "2000-01-01T00:00:00Z")) + self.assertTrue(self.later_than_alpha(cache.get_envelope(item_doc, "k").stored_at)) + + def test_only_the_stamp_shape_the_runtimes_write_parses(self) -> None: + """An RFC 3339 date-time with T, a fraction of 1 to 9 digits and Z or a numeric offset, on every interpreter (the shape is + checked here, not by fromisoformat), so a stamp reads the same on 3.10 and 3.13. The edges follow RFC 3339: offset hours 00-23, + minutes 00-59.""" + utc = timezone.utc + accepted = { + "2026-01-01T00:00:00Z": datetime(2026, 1, 1, tzinfo=utc), + "2026-01-01T00:00:00.1Z": datetime(2026, 1, 1, 0, 0, 0, 100000, utc), + "2026-01-01T00:00:00.123456789Z": datetime(2026, 1, 1, 0, 0, 0, 123456, utc), + "2026-01-01T00:00:00.1234567+00:00": datetime(2026, 1, 1, 0, 0, 0, 123456, utc), + "2026-01-01T05:30:00+05:30": datetime(2026, 1, 1, tzinfo=utc), + "2025-12-31T19:00:00-05:00": datetime(2026, 1, 1, tzinfo=utc), + "2026-01-01T23:59:00+23:59": datetime(2026, 1, 1, 0, 0, tzinfo=utc), + } + for text, instant in accepted.items(): + with self.subTest(text=text): + self.assertEqual(_parse_stored_at(text), instant) + for text in ( + "2026-01-01", "20260101T000000Z", "2026-W01-1T00:00:00Z", "2026-01-01 00:00:00Z", "2026-01-01T00:00:00,5Z", + "2026-01-01T00Z", "2026-01-01T00:00:00", "2026-01-01T00:00:00.Z", "2026-01-01T00:00:00.1234567890Z", + "2026-01-01T00:00:00z", " 2026-01-01T00:00:00Z", "2026-01-01T00:00:00Z ", "2026-01-01T24:00:00Z", + "2026-13-01T00:00:00Z", "2026-01-01T00:00:00+24:00", "2026-01-01T00:00:00+05:60", "2026-01-01T00:00:00+05:75", "2026-01-01T00:00Z", "2026-01-01T00:00+00:00", + "026-01-01T00:00:00Z", "12026-01-01T00:00:00Z", "2026-01-01T00:00:00+5:00", "2026-01-01T00:00:00+0000", "\u0662026-01-01T00:00:00Z", + "", "garbage", + ): + with self.subTest(text=text): + self.assertIsNone(_parse_stored_at(text)) + + def test_a_stamp_outside_the_range_is_unparseable_and_one_at_the_edge_refuses_a_later_stamp(self) -> None: + for text in ("9999-12-31T23:30:00-05:00", "0001-01-01T00:00:00+05:00"): + with self.subTest(text=text): + self.assertIsNone(_parse_stored_at(text)) + now = datetime(2030, 1, 1, tzinfo=timezone.utc) + self.assertEqual(mint_stored_at("9999-12-31T23:30:00-05:00", now), "2030-01-01T00:00:00.000000+00:00") + edge = "9999-12-31T23:59:59.9999999Z" + with self.assertRaisesRegex(ValueError, "last instant"): + mint_stored_at(edge, now) + with self.assertRaisesRegex(ValueError, "last instant"): + stored_at_over("2000-01-01T00:00:00Z", edge) + # A write over a record stamped at the edge is refused by the store and writes nothing, from push, commit_if and update. + store = SingleFileMessagePackBackingStore(self.path) + entry = item_doc.catalog_entry() + record = lambda value, at: CultCacheEnvelope("k", "item", item_doc.encode_payload(Item("n", value)), at, entry.schema_id, entry) # noqa: E731 + store.push(record(1, edge)) + before = self.bytes() + version = store.read("k")[1] + for write in (lambda: store.push(record(2, ALPHA)), lambda: store.push_all([record(2, ALPHA)]), lambda: store.commit_if(record(2, ALPHA), version)): + with self.assertRaises(ValueError): + write() + cache = self.cache() + apply = Counting() + with self.assertRaises(ValueError): + cache.update(item_doc, "k", apply) + self.assertEqual(apply.calls, 1) + self.assertEqual(self.bytes(), before) + self.assertEqual(cache.get_envelope(item_doc, "k").stored_at, edge) + + def test_stamp_parsing_and_install(self) -> None: + for text in ("2026-01-01T00:00:00.1234567+00:00", "2026-01-01T00:00:00Z", "2026-01-01T00:00:00.123456+00:00"): + with self.subTest(text=text): + self.assertIsNotNone(_parse_stored_at(text)) + self.assertEqual(_parse_stored_at(text).tzinfo, timezone.utc) + self.assertIsNone(_parse_stored_at("garbage")) + self.assertIsNone(_parse_stored_at(None)) + now = datetime(2030, 1, 1, tzinfo=timezone.utc) + self.assertEqual(mint_stored_at("garbage", now), "2030-01-01T00:00:00.000000+00:00") + self.assertEqual(stored_at_over("2000-01-01T00:00:00Z", "2000-01-01T00:00:00Z")[:4], str(datetime.now(timezone.utc).year)) # no later: minted at now + self.assertEqual(mint_stored_at("2030-01-01T00:00:00Z", now), "2030-01-01T00:00:00.000001+00:00") # equal is not later + self.assertEqual(mint_stored_at("2099-01-01T00:00:00.1234567Z", now), "2099-01-01T00:00:00.123457+00:00") + self.assertEqual(stored_at_over("2000-01-01T00:00:00Z", "2099-01-01T00:00:00.1234567Z"), "2099-01-01T00:00:00.123457+00:00") + + for reported in (True, 1, "garbage"): + with self.subTest(reported=reported): + class Lying(SingleFileMessagePackBackingStore): + def push(self, envelope): + super().push(envelope) + return reported + + cache = CultCache.builder().register_document_type(item_doc).add_generic_store(Lying(self.dir / f"lying-{reported}")).build() + cache.pull_all_backing_stores() + cache.put_envelope(item_doc, self.envelope(1, "2026-02-02T00:00:00.000000+00:00")) + self.assertEqual(cache.get_envelope(item_doc, "k").stored_at, "2026-02-02T00:00:00.000000+00:00") + + +def stamp_of(store, key: str) -> str: + return store.read(key)[0].stored_at + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/cultmesh-py/src/cultmesh_py/node.py b/packages/cultmesh-py/src/cultmesh_py/node.py index 619aed36..5ad917ae 100644 --- a/packages/cultmesh-py/src/cultmesh_py/node.py +++ b/packages/cultmesh-py/src/cultmesh_py/node.py @@ -570,12 +570,14 @@ class CultMeshDatabase: catalog_entry=catalog_entry, ) value = self.cache.put_envelope(registered, envelope) + # The stamp the write was stored at, which a replacing write mints later than the record it replaces: the cache holds it. + stored_at = self.cache.get_required_envelope(registered, key).stored_at self._publish_local_change(registered, key, "added" if previous is None else "updated", value, previous) message = document_put_raw( message_id=message_id, key=key, schema_id=envelope.schema_id or catalog_entry.schema_id, - stored_at=envelope.stored_at, + stored_at=stored_at, payload=envelope.payload, source_runtime_id=self.runtime_id, shard_id=shard_id, @@ -683,10 +685,11 @@ class CultMeshDatabase: value = self.cache.put_envelope(document, envelope) change_kind = "added" if previous is None else "updated" change = self._publish_local_change(document, record_key, change_kind, value, previous) - self._append_shard_log_put(CultNetMessage( - "cultnet.document_put_raw.v0", - {key: value for key, value in message.items() if key != "schemaVersion"}, - ), change_kind) + # The log carries the stamp the cache holds, which a replacing put mints later than the record it replaces, not the peer's. + stored_at = self.cache.get_required_envelope(document, record_key).stored_at + logged = {key: value for key, value in message.items() if key != "schemaVersion"} + logged["document"] = {**message["document"], "storedAt": stored_at} + self._append_shard_log_put(CultNetMessage("cultnet.document_put_raw.v0", logged), change_kind) return change def raw_put_served_alone(self, message: dict[str, Any], *, message_id: str) -> dict[str, Any] | None: diff --git a/packages/cultmesh-py/src/cultmesh_py/server.py b/packages/cultmesh-py/src/cultmesh_py/server.py index ab5267a7..8c8864fc 100644 --- a/packages/cultmesh-py/src/cultmesh_py/server.py +++ b/packages/cultmesh-py/src/cultmesh_py/server.py @@ -686,9 +686,11 @@ class CultMeshLocalServer: message_id=str(message.get("messageId") or ""), code="unregistered_document_put", )] + # Subscribers are sent the stamp the store holds, which a replacing put mints later than the record it replaces, not the peer's. + stored = self.node.database.cache.get_required_envelope(change.document, change.record_key).stored_at return self._database_change_notifications( message, - document_record, + {**document_record, "storedAt": stored}, change.change_kind, subscriptions, resolved_schema_id=change.schema_id, diff --git a/packages/cultmesh-py/tests/test_cultmesh.py b/packages/cultmesh-py/tests/test_cultmesh.py index d96c4ab2..4595f29c 100644 --- a/packages/cultmesh-py/tests/test_cultmesh.py +++ b/packages/cultmesh-py/tests/test_cultmesh.py @@ -19,6 +19,7 @@ from typing import Any, Callable import msgpack from cultcache_py import ( CultCache, + CultCacheEnvelope, SingleFileMessagePackBackingStore, define_database_entry_type, define_document_type, @@ -796,6 +797,51 @@ class CultMeshTests(unittest.TestCase): self.assertEqual(node.get_required(document, "note:second").body, "second catalog publication") self.assertIsInstance(node.get_required(alias, "note:second"), UiNote) + def test_raw_put_publishes_and_logs_the_stamp_the_store_wrote(self) -> None: + document = define_database_entry_type("mesh.stamp_item", [("name", 0), ("category", 1), ("value", 2, 0)], cls=Item) + node = create_node(runtime_id="python-node") + node.register_document(document) + cache = node.database.cache + entry = document.catalog_entry() + future = "2099-01-01T00:00:00.000000+00:00" + cache.put_envelope( + document, + CultCacheEnvelope( + key="item:stamp", + type=document.type, + payload=document.encode_payload(Item("old", "gear", 1)), + stored_at=future, + schema_id=entry.schema_id, + catalog_entry=entry, + ), + ) + # A replacing write is stored later than the record it replaces, so the stamp is not the one the write was created with. + wire = node.put_raw_message(document, "item:stamp", Item("new", "gear", 2), shard_id="mesh", shard_epoch=1).to_wire() + held = cache.get_required_envelope(document, "item:stamp").stored_at + self.assertGreater(held, future) + self.assertEqual(wire["document"]["storedAt"], held) + self.assertEqual(node.database._shard_logs["mesh"][-1]["put"]["document"]["storedAt"], held) + + def test_applied_raw_put_logs_the_stamp_the_cache_holds(self) -> None: + document = define_database_entry_type("mesh.applied_stamp", [("name", 0), ("category", 1), ("value", 2, 0)], cls=Item) + node = create_node(runtime_id="python-node") + node.register_document(document) + cache = node.database.cache + entry = document.catalog_entry() + + def envelope(value: int, stored_at: str) -> CultCacheEnvelope: + return CultCacheEnvelope("item:applied", document.type, document.encode_payload(Item("n", "gear", value)), stored_at, entry.schema_id, entry) + + cache.put_envelope(document, envelope(1, "2099-01-01T00:00:00.000000+00:00")) + peer = node.put_raw_message(document, "item:peer", Item("peer", "gear", 2)).to_wire() # a well-formed put to replay + peer["document"] = {**peer["document"], "recordKey": "item:applied", "storedAt": "2000-01-01T00:00:00Z", + "payload": envelope(2, "x").payload} + peer["shardId"], peer["shardEpoch"] = "mesh", 1 + node.database.apply_raw_put_message(peer) + held = cache.get_required_envelope(document, "item:applied").stored_at + self.assertGreater(held, "2099-01-01T00:00:00.000000+00:00") + self.assertEqual(node.database._shard_logs["mesh"][-1]["put"]["document"]["storedAt"], held) + def test_cultmesh_node_emits_raw_put_and_delete_messages_for_local_writes(self) -> None: document = define_database_entry_type( "mesh.emit_item", diff --git a/packages/cultmesh-py/tests/test_remote_write_authority.py b/packages/cultmesh-py/tests/test_remote_write_authority.py index 3844e589..23ce8c10 100644 --- a/packages/cultmesh-py/tests/test_remote_write_authority.py +++ b/packages/cultmesh-py/tests/test_remote_write_authority.py @@ -8,6 +8,7 @@ the peer the connection loop would pass, and one test drives the same path over from __future__ import annotations import json +import tempfile import unittest from pathlib import Path from typing import Any @@ -23,7 +24,7 @@ from cultmesh_py import ( CultMeshServerPeer, create_node, ) -from cultmesh_py.server import CultMeshLocalServer +from cultmesh_py.server import CultMeshLocalServer, _DatabaseSubscription from cultnet_py import CultNetPeerError, create_rudp_schema_transport, document_delete, document_put_raw, error_message SCHEMA_ID = "sha256:authority-note" @@ -55,12 +56,12 @@ class Recording: return self._decide(request) -def put_of(key: str, text: str, *, schema_id: str = SCHEMA_ID) -> dict[str, Any]: +def put_of(key: str, text: str, *, schema_id: str = SCHEMA_ID, stored_at: str = "2026-11-07T00:00:00Z") -> dict[str, Any]: return document_put_raw( message_id="wire", key=key, schema_id=schema_id, - stored_at="2026-11-07T00:00:00Z", + stored_at=stored_at, payload=NOTE.encode_payload({"schema_version": "tests.authority_note.v1", "text": text}), shard_id=SHARD_ID, shard_epoch=1, @@ -73,6 +74,26 @@ def delete_of(key: str, *, schema_id: str = SCHEMA_ID) -> dict[str, Any]: ).to_wire() +class NotificationStampTests(unittest.TestCase): + """A subscriber is sent the stamp the store holds after a peer's put, as the shard log is, not the peer's stamp.""" + + def test_the_notification_carries_the_stamp_the_cache_holds(self) -> None: + local, peer_stamp = "2099-01-01T00:00:00.000000+00:00", "2000-01-01T00:00:00Z" + with tempfile.TemporaryDirectory() as directory: + for backing in (None, Path(directory) / "node.cc"): # store-less, then store-backed + with self.subTest(store=backing is not None): + node = create_node(backing, runtime_id="stamp-server") + node.database.register_document(NOTE) + server = CultMeshLocalServer(node=node, port=0, write_authority=CultMeshGrantAllWrites()) + node.database.apply_raw_put_message(put_of(KEY_A, "server", stored_at=local)) + subscriptions = {"s": _DatabaseSubscription("s", set(), set())} + replies = server._handle_connection_message(put_of(KEY_A, "peer", stored_at=peer_stamp), subscriptions, peer=PEER) + held = node.cache.get_required_envelope(NOTE, KEY_A).stored_at + self.assertGreater(held, local) + self.assertEqual([reply["document"]["storedAt"] for reply in replies], [held]) + self.assertEqual(node.database._shard_logs[SHARD_ID][-1]["put"]["document"]["storedAt"], held) + + class RemoteWriteAuthorityTests(unittest.TestCase): def build(self, authority: Any = None) -> CultMeshLocalServer: node = create_node(runtime_id="authority-server") From c88b939e76f24296c14ea844d730e8c74cfd11f1 Mon Sep 17 00:00:00 2001 From: Libby Date: Sat, 14 Nov 2026 04:32:55 +0000 Subject: [PATCH 92/93] cultmesh-py: refuse a peer raw put with no stamp (raw-put-refuse-py) (#174) --- packages/cultcache-py/CHANGELOG.md | 4 ++ .../cultcache-py/src/cultcache_py/cache.py | 4 +- .../cultcache-py/src/cultcache_py/stores.py | 6 +-- packages/cultcache-py/tests/test_update.py | 18 ++++----- packages/cultmesh-py/src/cultmesh_py/node.py | 26 +++++++++---- .../cultmesh-py/src/cultmesh_py/server.py | 5 ++- .../tests/test_remote_write_authority.py | 38 +++++++++++++++++++ 7 files changed, 78 insertions(+), 23 deletions(-) diff --git a/packages/cultcache-py/CHANGELOG.md b/packages/cultcache-py/CHANGELOG.md index 8f3e481d..30b60850 100644 --- a/packages/cultcache-py/CHANGELOG.md +++ b/packages/cultcache-py/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Changed + +- `parse_stored_at` (was `_parse_stored_at`) is public: cultmesh-py judges a peer's `storedAt` by it. A peer's `document_put_raw` whose `storedAt` is missing, empty or not a stamp is refused `message_invalid` (field `document`) and nothing is minted or stored (variants:ruling:raw-put-without-stamp-refused). A subscribed server's stampless put now raises `ValueError` from `CultMeshClient.read_next_change` instead of being stored under a minted stamp. + ### Added - `CultCache.update` and `update_global` return `UpdateResult(outcome, document)`; `UpdateOutcome` is `UPDATED`, `DECLINED` or `LOST_RACE`. The single-file store gains `read(key)` (the record and its content version, the SHA-256 C# computes) and `commit_if(envelope, expected)`, the version is the SHA-256 of the record re-encoded as `[key, schemaId, storedAt, payload]` with minimal MessagePack encodings, which is the SHA-256 of the record's stored bytes exactly when the writer encoded it minimally (every writer in this library does; a record another writer encoded with a wider-than-needed length or integer has the version of its minimal form, not of its bytes). Both looked up by the cache and not `BackingStore` protocol members (variants:cut_spec:cut-update-with-retry-py). diff --git a/packages/cultcache-py/src/cultcache_py/cache.py b/packages/cultcache-py/src/cultcache_py/cache.py index 0a396f61..f82bd7d5 100644 --- a/packages/cultcache-py/src/cultcache_py/cache.py +++ b/packages/cultcache-py/src/cultcache_py/cache.py @@ -10,7 +10,7 @@ from typing import Any, Callable, Generic, Iterable, TypeVar from .backing_store import BackingStore, CultCacheEnvelope from .documents import DocumentDefinition, UnreadSlots, extract_value -from .stores import SchemaConflictError, _parse_stored_at, retyped, stored_at_over, written_owns +from .stores import SchemaConflictError, parse_stored_at, retyped, stored_at_over, written_owns T = TypeVar("T") F = TypeVar("F", bound=Callable[..., Any]) @@ -650,7 +650,7 @@ class CultCache: self._drop(other, key) for envelope, value, keys in admitted: stamp = stamps.get(envelope.key) - if isinstance(stamp, str) and _parse_stored_at(stamp) is not None: + if isinstance(stamp, str) and parse_stored_at(stamp) is not None: envelope = replace(envelope, stored_at=stamp) self._remove_lookups(type, envelope.key) values[envelope.key] = value diff --git a/packages/cultcache-py/src/cultcache_py/stores.py b/packages/cultcache-py/src/cultcache_py/stores.py index f081417c..99e38d0d 100644 --- a/packages/cultcache-py/src/cultcache_py/stores.py +++ b/packages/cultcache-py/src/cultcache_py/stores.py @@ -780,7 +780,7 @@ _STORED_AT_SHAPE = re.compile( ) -def _parse_stored_at(text: Any) -> datetime | None: +def parse_stored_at(text: Any) -> datetime | None: """The UTC instant a stored_at text names, or None. Only the shape the runtimes write is a stamp: an RFC 3339 date-time with T, an optional fraction of 1 to 9 digits (cut to the microsecond) and Z or a numeric offset of hours 00-23 and minutes 00-59. Checked here, not left to fromisoformat, whose accepted set differs by interpreter (3.10 and 3.13) and from Rust and TypeScript. An instant outside @@ -808,7 +808,7 @@ def mint_stored_at(previous: str | None, now: datetime) -> str: A previous that does not parse leaves `now`. Raises ValueError, nothing minted, when `previous` is the last instant a stamp can name, so a write over it is refused and writes nothing.""" now = now.astimezone(timezone.utc) - last = _parse_stored_at(previous) + last = parse_stored_at(previous) if last is not None and now <= last: try: now = last + timedelta(microseconds=1) @@ -820,7 +820,7 @@ def mint_stored_at(previous: str | None, now: datetime) -> str: def stored_at_over(staged: str, replaced: str) -> str: """The stored_at a record replacing `replaced` is written with (C# StoredAtOver): `staged` when both parse and it is strictly later, else one minted over `replaced`. No content is compared: every replacing write is stamped later.""" - later, earlier = _parse_stored_at(staged), _parse_stored_at(replaced) + later, earlier = parse_stored_at(staged), parse_stored_at(replaced) if later is not None and earlier is not None and later > earlier: return staged return mint_stored_at(replaced, datetime.now(timezone.utc)) diff --git a/packages/cultcache-py/tests/test_update.py b/packages/cultcache-py/tests/test_update.py index 34f030a8..107adf3b 100644 --- a/packages/cultcache-py/tests/test_update.py +++ b/packages/cultcache-py/tests/test_update.py @@ -26,7 +26,7 @@ from cultcache_py import ( leaves as t, ) from cultcache_py.cache import GLOBAL_KEY, CultCacheError -from cultcache_py.stores import _parse_stored_at, mint_stored_at, record_version, stored_at_over +from cultcache_py.stores import parse_stored_at, mint_stored_at, record_version, stored_at_over @dataclass @@ -578,7 +578,7 @@ class StoreRestampTests(Fixture): return next(envelope.stored_at for envelope in store.pull_all() if envelope.key == key) def later_than_alpha(self, text: str) -> bool: - return _parse_stored_at(text) > _parse_stored_at(ALPHA) + return parse_stored_at(text) > parse_stored_at(ALPHA) def test_commit_if_and_the_restamp_at_the_store(self) -> None: store = SingleFileMessagePackBackingStore(self.path) @@ -655,7 +655,7 @@ class StoreRestampTests(Fixture): } for text, instant in accepted.items(): with self.subTest(text=text): - self.assertEqual(_parse_stored_at(text), instant) + self.assertEqual(parse_stored_at(text), instant) for text in ( "2026-01-01", "20260101T000000Z", "2026-W01-1T00:00:00Z", "2026-01-01 00:00:00Z", "2026-01-01T00:00:00,5Z", "2026-01-01T00Z", "2026-01-01T00:00:00", "2026-01-01T00:00:00.Z", "2026-01-01T00:00:00.1234567890Z", @@ -665,12 +665,12 @@ class StoreRestampTests(Fixture): "", "garbage", ): with self.subTest(text=text): - self.assertIsNone(_parse_stored_at(text)) + self.assertIsNone(parse_stored_at(text)) def test_a_stamp_outside_the_range_is_unparseable_and_one_at_the_edge_refuses_a_later_stamp(self) -> None: for text in ("9999-12-31T23:30:00-05:00", "0001-01-01T00:00:00+05:00"): with self.subTest(text=text): - self.assertIsNone(_parse_stored_at(text)) + self.assertIsNone(parse_stored_at(text)) now = datetime(2030, 1, 1, tzinfo=timezone.utc) self.assertEqual(mint_stored_at("9999-12-31T23:30:00-05:00", now), "2030-01-01T00:00:00.000000+00:00") edge = "9999-12-31T23:59:59.9999999Z" @@ -699,10 +699,10 @@ class StoreRestampTests(Fixture): def test_stamp_parsing_and_install(self) -> None: for text in ("2026-01-01T00:00:00.1234567+00:00", "2026-01-01T00:00:00Z", "2026-01-01T00:00:00.123456+00:00"): with self.subTest(text=text): - self.assertIsNotNone(_parse_stored_at(text)) - self.assertEqual(_parse_stored_at(text).tzinfo, timezone.utc) - self.assertIsNone(_parse_stored_at("garbage")) - self.assertIsNone(_parse_stored_at(None)) + self.assertIsNotNone(parse_stored_at(text)) + self.assertEqual(parse_stored_at(text).tzinfo, timezone.utc) + self.assertIsNone(parse_stored_at("garbage")) + self.assertIsNone(parse_stored_at(None)) now = datetime(2030, 1, 1, tzinfo=timezone.utc) self.assertEqual(mint_stored_at("garbage", now), "2030-01-01T00:00:00.000000+00:00") self.assertEqual(stored_at_over("2000-01-01T00:00:00Z", "2000-01-01T00:00:00Z")[:4], str(datetime.now(timezone.utc).year)) # no later: minted at now diff --git a/packages/cultmesh-py/src/cultmesh_py/node.py b/packages/cultmesh-py/src/cultmesh_py/node.py index 5ad917ae..ff6ef8f2 100644 --- a/packages/cultmesh-py/src/cultmesh_py/node.py +++ b/packages/cultmesh-py/src/cultmesh_py/node.py @@ -12,6 +12,7 @@ from typing import Any, Callable from cultcache_py import CultCache, CultCacheEnvelope, SingleFileMessagePackBackingStore from cultcache_py.cache import CultCacheError, index_text from cultcache_py.documents import DocumentDefinition, extract_value +from cultcache_py.stores import parse_stored_at from cultnet_py import ( CultNetAppliedRecord, CultNetFileShardMutationLogStore, @@ -664,10 +665,10 @@ class CultMeshDatabase: ): return None try: - prepared = self._raw_put_envelope(message) + document = self._raw_put_document(message) except Exception: # noqa: BLE001 - any other malformed shape return None - return None if prepared is None else (prepared[0].catalog_entry().schema_id, prepared[1].key) + return None if document is None else (document.catalog_entry().schema_id, document_record["recordKey"]) def raw_delete_target(self, message: dict[str, Any]) -> tuple[str, str] | None: """The canonical schema id and record key a raw delete names, or None when its schema is not registered.""" @@ -706,25 +707,34 @@ class CultMeshDatabase: documents=(self._served_record(prepared[1]),), ).to_wire() - def _raw_put_envelope( - self, message: dict[str, Any] - ) -> tuple[DocumentDefinition[Any], CultCacheEnvelope] | None: + def _raw_put_document(self, message: dict[str, Any]) -> DocumentDefinition[Any] | None: + """The registered document a raw put names, or None when its record is not a map or its schema is not registered.""" document_record = message.get("document") if not isinstance(document_record, dict): return None schema_id = str(document_record.get("schemaId") or "") - documents_by_schema_id = schema_document_map(self.documents) try: - document = resolve_document_for_raw_record(documents_by_schema_id, schema_id, document_record) + return resolve_document_for_raw_record(schema_document_map(self.documents), schema_id, document_record) except KeyError: return None + + def _raw_put_envelope( + self, message: dict[str, Any] + ) -> tuple[DocumentDefinition[Any], CultCacheEnvelope] | None: + document = self._raw_put_document(message) + if document is None: + return None + document_record = message["document"] + # A peer's put is stored under the stamp it carries, so one that is no stamp is refused: nothing is minted for it. + if parse_stored_at(document_record.get("storedAt")) is None: + raise ValueError("A raw put must carry a storedAt stamp.") # One id: the local schema id, under which the cache stores the record. return document, CultCacheEnvelope( key=str(document_record["recordKey"]), type=document.type, schema_id=document.catalog_entry().schema_id, payload=bytes(document_record["payload"]), - stored_at=str(document_record.get("storedAt") or ""), + stored_at=document_record["storedAt"], catalog_entry=document.catalog_entry(), ) diff --git a/packages/cultmesh-py/src/cultmesh_py/server.py b/packages/cultmesh-py/src/cultmesh_py/server.py index 8c8864fc..363cddbf 100644 --- a/packages/cultmesh-py/src/cultmesh_py/server.py +++ b/packages/cultmesh-py/src/cultmesh_py/server.py @@ -10,6 +10,7 @@ from typing import Any import msgpack +from cultcache_py.stores import parse_stored_at from cultnet_py import ( error_message, message_invalid_error, @@ -969,7 +970,7 @@ class CultMeshLocalServer: as a snapshot would serve it under the shortest message id, must fit max_snapshot_bytes; a put arriving over RUDP must also fit, as fragments of rudp_max_fragment_bytes, in one message and the peer's reliable queue. A put whose document has no string recordKey or - binary payload could never be stored or served, and is refused as malformed.""" + binary payload, or a storedAt that is no stamp, could never be stored or served, and is refused as malformed.""" document_record = message.get("document") if not ( isinstance(document_record, dict) @@ -977,6 +978,8 @@ class CultMeshLocalServer: and isinstance(document_record.get("payload"), (bytes, bytearray)) ): return self._message_invalid(message, "document", "Raw put documents must carry a string recordKey and a binary payload.") + if parse_stored_at(document_record.get("storedAt")) is None: + return self._message_invalid(message, "document", "Raw put documents must carry a storedAt stamp.") if self.max_snapshot_bytes is None and not over_rudp: return None alone = self.node.database.raw_put_served_alone(message, message_id=_SHORTEST_SERVED_MESSAGE_ID) diff --git a/packages/cultmesh-py/tests/test_remote_write_authority.py b/packages/cultmesh-py/tests/test_remote_write_authority.py index 23ce8c10..7397577d 100644 --- a/packages/cultmesh-py/tests/test_remote_write_authority.py +++ b/packages/cultmesh-py/tests/test_remote_write_authority.py @@ -94,6 +94,44 @@ class NotificationStampTests(unittest.TestCase): self.assertEqual(node.database._shard_logs[SHARD_ID][-1]["put"]["document"]["storedAt"], held) +class StamplessPutTests(unittest.TestCase): + """A peer's put with no storedAt, or one that is no stamp, is refused message_invalid; nothing is minted or stored.""" + + def test_a_put_without_a_stamp_is_refused_and_the_store_stays_writable(self) -> None: + with tempfile.TemporaryDirectory() as directory: + node = create_node(Path(directory) / "node.cc", runtime_id="stampless-server") + node.database.register_document(NOTE) + server = CultMeshLocalServer(node=node, port=0, write_authority=CultMeshGrantAllWrites()) + sized = CultMeshLocalServer(node=node, port=0, write_authority=CultMeshGrantAllWrites(), max_snapshot_bytes=10**6) + rudp = CultMeshLocalServer(node=node, port=0, write_authority=CultMeshGrantAllWrites()) + for label, stamp in (("missing", None), ("empty", ""), ("garbage", "yesterday"), ("no zone", "2026-11-07T00:00:00")): + with self.subTest(label): + message = put_of(KEY_A, "peer") + del message["document"]["storedAt"] + if stamp is not None: + message["document"]["storedAt"] = stamp + # The size-limited and RUDP paths measure the document as a snapshot would serve it, so they must refuse first. + for limited in (server, sized, rudp): + [refusal] = limited._handle_connection_message(message, {}, over_rudp=limited is rudp, peer=PEER) + self.assertEqual(refusal["code"], "message_invalid") + self.assertEqual(refusal["details"]["field"], "document") + with self.assertRaises(ValueError): + node.database.apply_raw_put_message(message) + self.assertIsNone(node.cache.get(NOTE, KEY_A)) + self.assertNotIn(SHARD_ID, node.database._shard_logs) + # An aliased put with a bad stamp still asks the authority about the canonical id, and is refused before the stamp is judged. + authority = Recording(lambda request: CultMeshRemoteWriteDecision.refuse("no")) + guarded = CultMeshLocalServer(node=node, port=0, write_authority=authority) + aliased = put_of(KEY_A, "peer", schema_id=ALIAS, stored_at="yesterday") + [refusal] = guarded._handle_connection_message(aliased, {}, peer=PEER) + self.assertEqual(refusal["code"], "write_not_authorized") + self.assertEqual(authority.requests[0].schema_id, SCHEMA_ID) + # The refused puts left the file readable: the next put of any key is stored. + replies = server._handle_connection_message(put_of(KEY_B, "stamped"), {}, peer=PEER) + self.assertEqual(replies, []) + self.assertEqual(node.cache.get(NOTE, KEY_B)["text"], "stamped") + + class RemoteWriteAuthorityTests(unittest.TestCase): def build(self, authority: Any = None) -> CultMeshLocalServer: node = create_node(runtime_id="authority-server") From 75188fdefa0b4baf131dbfd94e77d06a965f0cc6 Mon Sep 17 00:00:00 2001 From: Libby Date: Sat, 14 Nov 2026 08:51:04 +0000 Subject: [PATCH 93/93] Stamp hardening: refuse a peer raw put with no stamp in C#, TypeScript and Rust; Python replication (#175) --- .../interop/write-receipt-vectors.json | 4 +- contracts/cultnet/interop/write-receipt.md | 2 +- packages/cultcache-rs/CHANGELOG.md | 1 + packages/cultcache-rs/src/lib.rs | 83 +++++-- .../tests/stored_at_stamp_vector.rs | 58 +++++ packages/cultcache-ts/CHANGELOG.md | 3 + packages/cultcache-ts/package.json | 2 +- packages/cultcache-ts/src/browser.ts | 1 + packages/cultcache-ts/src/stored-at.ts | 33 +++ .../cultcache-ts/test/stored-at-stamp.test.ts | 20 ++ packages/cultmesh-browser/package.json | 2 +- packages/cultmesh-py/src/cultmesh_py/node.py | 17 +- .../tests/test_stampless_replication.py | 212 ++++++++++++++++++ packages/cultmesh-rs/src/lib.rs | 24 ++ .../cultmesh-rs/src/rudp_document_server.rs | 25 ++- .../cultmesh-rs/tests/rudp_document_server.rs | 178 +++++++++++++++ packages/cultmesh-ts/CHANGELOG.md | 1 + packages/cultmesh-ts/package.json | 2 +- packages/cultmesh-ts/src/index.ts | 9 +- packages/cultmesh-ts/test/cultmesh.test.ts | 7 +- .../test/remote-write-authority.test.ts | 59 +++++ packages/cultnet-py/CHANGELOG.md | 1 + .../cultnet-py/src/cultnet_py/replication.py | 21 +- .../cultnet-py/tests/test_raw_record_stamp.py | 57 +++++ packages/cultnet-ts/CHANGELOG.md | 1 + packages/cultnet-ts/package.json | 2 +- packages/cultnet-ts/src/contracts.ts | 9 +- packages/cultnet-ts/src/replication.ts | 5 + .../cultnet-ts/test/raw-put-stamp.test.ts | 53 +++++ src/GameCult.Networking/CultNetDatabase.cs | 7 + .../CultNetDatabaseServer.cs | 5 + .../CultNetStoredAtStamp.cs | 63 ++++++ .../CultNetWriteReceiptServerTests.cs | 2 + .../RemoteWriteAuthorityTests.cs | 108 +++++++++ tests/vectors/stored-at-stamp/README.md | 11 + tests/vectors/stored-at-stamp/stamps.msgpack | Bin 0 -> 1162 bytes 36 files changed, 1043 insertions(+), 45 deletions(-) create mode 100644 packages/cultcache-rs/tests/stored_at_stamp_vector.rs create mode 100644 packages/cultcache-ts/src/stored-at.ts create mode 100644 packages/cultcache-ts/test/stored-at-stamp.test.ts create mode 100644 packages/cultmesh-py/tests/test_stampless_replication.py create mode 100644 packages/cultnet-py/tests/test_raw_record_stamp.py create mode 100644 packages/cultnet-ts/test/raw-put-stamp.test.ts create mode 100644 src/GameCult.Networking/CultNetStoredAtStamp.cs create mode 100644 tests/vectors/stored-at-stamp/README.md create mode 100644 tests/vectors/stored-at-stamp/stamps.msgpack diff --git a/contracts/cultnet/interop/write-receipt-vectors.json b/contracts/cultnet/interop/write-receipt-vectors.json index e95c3180..04f50309 100644 --- a/contracts/cultnet/interop/write-receipt-vectors.json +++ b/contracts/cultnet/interop/write-receipt-vectors.json @@ -115,7 +115,7 @@ "validPutPayloadHex": "81A16101", "whiteSpaceCodePoints": ["0009","000A","000B","000C","000D","0020","0085","00A0","1680","2000","2001","2002","2003","2004","2005","2006","2007","2008","2009","200A","2028","2029","202F","205F","3000"], "notWhiteSpaceCodePoints": ["200B","200C","200D","2060","FEFF","180E","001C","001D","001E","001F"], - "codePointCases": "For every code point in whiteSpaceCodePoints, each of schemaId, recordKey, storedAt, sourceRuntimeId, sourceAgentId, sourceRole and one tags entry, set alone to a string of just that code point (and to that code point repeated three times), is refused; for every code point in notWhiteSpaceCodePoints the same string is not blank and the put is admitted.", + "codePointCases": "For every code point in whiteSpaceCodePoints, each of schemaId, recordKey, storedAt, sourceRuntimeId, sourceAgentId, sourceRole and one tags entry, set alone to a string of just that code point (and to that code point repeated three times), is refused; for every code point in notWhiteSpaceCodePoints the same string in each of those fields except storedAt is not blank and the put is admitted (a storedAt must be a stamp: a non-blank storedAt that is no stamp is refused message_invalid, field document, after the write authority, and is covered by tests/vectors/stored-at-stamp).", "cases": [ { "name": "valid_document_is_admitted", "set": {}, "outcome": "admitted" }, { "name": "empty_schema_id", "set": { "schemaId": "" }, "outcome": "refused" }, @@ -140,7 +140,7 @@ { "name": "zero_width_space_schema_id_is_a_name", "set": { "schemaId": "\u200b" }, "outcome": "admitted" }, { "name": "zero_width_space_record_key_is_a_name", "set": { "recordKey": "\u200b" }, "outcome": "admitted" }, { "name": "zero_width_space_tag_is_a_name", "set": { "tags": ["\u200b"] }, "outcome": "admitted" }, - { "name": "bom_stored_at_is_a_name", "set": { "storedAt": "\ufeff" }, "outcome": "admitted" }, + { "name": "bom_record_key_is_a_name", "set": { "recordKey": "\ufeff" }, "outcome": "admitted" }, { "name": "mongolian_vowel_separator_source_role_is_a_name", "set": { "sourceRole": "\u180e" }, "outcome": "admitted" }, { "name": "padded_name_is_a_name", "set": { "schemaId": "\u00a0a\u2003", "tags": [" a"] }, "outcome": "admitted" }, { "name": "file_separator_schema_id_is_a_name", "set": { "schemaId": "\u001c" }, "outcome": "admitted" }, diff --git a/contracts/cultnet/interop/write-receipt.md b/contracts/cultnet/interop/write-receipt.md index bfcbbb86..121f77f1 100644 --- a/contracts/cultnet/interop/write-receipt.md +++ b/contracts/cultnet/interop/write-receipt.md @@ -40,7 +40,7 @@ A connection is receipt-capable when its `cultnet.hello.v0` (sent before its fir - **What is remembered.** Per entry a server keeps only fixed-size digests (SHA-256) of the `messageId` and of the content hash, plus the answer (`admitted`, or the refusal's code and details). Memory per entry is bounded independent of id length and payload size. Lookup compares digests. - **Remembered-write limits.** The per-session table holds at most 1024 entries. A server MAY also enforce a server-wide memory budget across all sessions (reference: 32 MiB of remembered entries). A NEW write (a key not already in the table) that would exceed either bound is refused `limit_reached` for that write alone, `messageId` the write's id, `details` all null; the write is not applied and the session continues. A resend of a remembered key is still answered from the table. - **Limit refusals are remembered.** A `limit_reached` refusal is remembered like any other: a resend of the same id and content hash replays `limit_reached`. No slot is freed mid-session, so an id refused at a bound never becomes admitted later in that session (a server at the cap keeps the refused digest pair in a bounded refusal set of at most 1024 per session, outside the entry budget; past it, a further new write is refused `limit_reached` without being remembered, and the session refuses every id it has not seen for the rest of its life, even if a server-wide budget has since freed, so a refusal that was not remembered is never followed by an admission of the same id). The client mints a new id on a new session. - - **Required fields.** A put or delete whose document or `schemaId` is null or missing is refused `message_invalid` with `details.field` `"document"` or `"schemaId"` (the first missing in that order) and fixed text; the write is not applied. A put or delete that is present but names nothing is not "missing": the write is refused `message_invalid` exactly as a frame that does not read, with `details.field` `"message"`, the fixed unreadable text and the write's id (vector `error_v1_empty_schema_id_is_unreadable_and_names_the_write`), and is never applied; a server must not hand it to its store. Names nothing means, on a put or delete, a `schemaId` or `recordKey` that is blank; on a put's document, also a `storedAt` that is blank, a `payload` of zero bytes (a MessagePack value is at least one byte, so an empty payload is no document), a `sourceRuntimeId`, `sourceAgentId` or `sourceRole` that is present and blank, and a `tags` entry that is blank. **Blank** means empty or made only of Unicode White_Space code points (the PropList property) and nothing else, in every runtime, whatever its platform's own whitespace test says. The set is exactly U+0009 to U+000D, U+0020, U+0085, U+00A0, U+1680, U+2000 to U+200A, U+2028, U+2029, U+202F, U+205F and U+3000. U+200B, U+200C, U+200D, U+2060, U+FEFF, U+180E and U+001C to U+001F are not White_Space (some platforms' whitespace tests accept U+001C to U+001F or U+180E, for example Java's `Character.isWhitespace` and older .NET, so a runtime must check the list, not its platform test): a string of them names something. The vectors list both sets (`whiteSpaceCodePoints`, `notWhiteSpaceCodePoints`) and a runtime checks every code point in every covered field. Absent or nil source ids and tags, and an empty `tags` list, are valid. The rule holds on every connection, receipt-capable or not (a legacy connection is refused with the `error.v0` of the same code, details and text); the vectors' `blankFields` section lists each case. + - **Required fields.** A put or delete whose document or `schemaId` is null or missing is refused `message_invalid` with `details.field` `"document"` or `"schemaId"` (the first missing in that order) and fixed text; the write is not applied. A put or delete that is present but names nothing is not "missing": the write is refused `message_invalid` exactly as a frame that does not read, with `details.field` `"message"`, the fixed unreadable text and the write's id (vector `error_v1_empty_schema_id_is_unreadable_and_names_the_write`), and is never applied; a server must not hand it to its store. Names nothing means, on a put or delete, a `schemaId` or `recordKey` that is blank; on a put's document, also a `storedAt` that is blank, a `payload` of zero bytes (a MessagePack value is at least one byte, so an empty payload is no document), a `sourceRuntimeId`, `sourceAgentId` or `sourceRole` that is present and blank, and a `tags` entry that is blank. **Blank** means empty or made only of Unicode White_Space code points (the PropList property) and nothing else, in every runtime, whatever its platform's own whitespace test says. The set is exactly U+0009 to U+000D, U+0020, U+0085, U+00A0, U+1680, U+2000 to U+200A, U+2028, U+2029, U+202F, U+205F and U+3000. U+200B, U+200C, U+200D, U+2060, U+FEFF, U+180E and U+001C to U+001F are not White_Space (some platforms' whitespace tests accept U+001C to U+001F or U+180E, for example Java's `Character.isWhitespace` and older .NET, so a runtime must check the list, not its platform test): a string of them names something. The vectors list both sets (`whiteSpaceCodePoints`, `notWhiteSpaceCodePoints`) and a runtime checks every code point in every covered field. A put's `storedAt` is the exception to "present and not blank is a name": it must be a stamp, so a non-blank `storedAt` that is no stamp (`tests/vectors/stored-at-stamp`) is refused `message_invalid` with `details.field` `"document"`, after the write authority has decided (an ungranted peer is told `write_not_authorized` first), and nothing is stored or minted; only a blank `storedAt` is refused as unreadable with field `"message"`, before the authority. The non-White_Space code point rows therefore name something in every covered field except `storedAt`. Absent or nil source ids and tags, and an empty `tags` list, are valid. The rule holds on every connection, receipt-capable or not (a legacy connection is refused with the `error.v0` of the same code, details and text); the vectors' `blankFields` section lists each case. - **Fixed texts.** The `error` string of these refusals is fixed, so vectors can compare it: `message_invalid: messageId exceeds 256 bytes`, `message_invalid: document is required`, `message_invalid: schemaId is required`, `message_invalid: the message could not be read` (a frame that does not decode, or a write that names nothing; the text is the same for both and never carries a decoder's own wording), `message_invalid: receipt sessions require the reliable channel`, `limit_reached: the session's remembered-write limit is full`. - **Reliable channel only.** A write is accepted on a receipt session only on a channel that is reliable and not sequenced (reliable ordered and reliable unordered both qualify; a sequenced channel drops superseded packets, so a write on it can vanish without trace). A write arriving on any other channel is refused `message_invalid` with `details.field: "channel"` (the transport profile's word for it) and fixed text. Receipts and refusals travel the reliable channel; a write that could be lost without trace cannot be answered once. The server refuses a sequenced write whenever it can read the write's id: from the delivered frame, and from a superseded packet that carries its whole frame. A write sent as several fragments whose frame is superseded is dropped whole and may go unanswered; the client's unconfirmed outcome covers it. A repeat of a packet the server already took, or a packet too far ahead to be taken, draws no further answer (rule 1). - Past the session (or a server's longer retention, if it keeps one) a resend is a new write. diff --git a/packages/cultcache-rs/CHANGELOG.md b/packages/cultcache-rs/CHANGELOG.md index 0cbf6708..23e217e8 100644 --- a/packages/cultcache-rs/CHANGELOG.md +++ b/packages/cultcache-rs/CHANGELOG.md @@ -13,6 +13,7 @@ only in git history. ### Changed +- `is_stored_at_stamp(&str)` (new, public) is the one shape a peer's `storedAt` must parse as, pinned by `tests/vectors/stored-at-stamp` (RFC 3339 `YYYY-MM-DDThh:mm:ss`, a 1 to 9 digit fraction, `Z` or `+hh:mm`/`-hh:mm`; uppercase `T`/`Z`, a real date, seconds 00-59, years 0001-9999), hand-checked because `chrono`'s RFC 3339 reader takes other shapes. `put_raw_envelope` and `put_prepared_batch` refuse a `stored_at` that is not a stamp (before: only a blank one) and mint nothing (variants:ruling:raw-put-without-stamp-refused). - The single-file store's `push`, `push_all` and `commit_if` stamp a replacing write strictly later than the record it replaces at the same key (a staged stamp stands only when it parses and is strictly later; otherwise the stamp is minted as the replaced stamp plus one microsecond, or now if later), with no identical-content exception, as C# does. A stamp with no successor (the replaced stamp is at year 9999's last microsecond) refuses the write and writes nothing, as C# `MintStoredAt` does. `commit_if` replaces only the record of the entry's own type under the key, so a retype is refused as `push` refuses it. The cache holds the stamp the store wrote. The redb stores and the store-level exact-envelope writes (`compare_and_swap_entry` and its family) still write the caller's envelope unchanged. - The single-file store re-checks for a link once the store lock is held (every write and the try-exchange), so a link put at the path while a writer waited on the lock is refused with `StoreLinkedError` and the lock released, as C# does, instead of being replaced. Pinned by a test that puts the link while a second holder owns the lock. - `CultCache::is_writable` does not look at store paths: a link at a store path is not predicted, and the write refuses it with `StoreLinkedError`. Pinned by a test through a cache attached to a linked store. diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index cd18aea8..0e60a0c2 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -3761,12 +3761,7 @@ impl CultCache { entry.r#type )); } - if entry.stored_at.trim().is_empty() { - return Err(anyhow!( - "CultCache envelope stored_at for type {:?} must be non-empty", - entry.r#type - )); - } + require_stored_at_stamp(entry)?; require_record_payload(entry)?; self.refuse_unread_slots(&entry.r#type, &entry.key)?; self.refuse_claimed_id(entry)?; @@ -3843,12 +3838,7 @@ impl CultCache { entry.r#type )); } - if entry.stored_at.trim().is_empty() { - return Err(anyhow!( - "CultCache envelope stored_at for type {:?} must be non-empty", - entry.r#type - )); - } + require_stored_at_stamp(&entry)?; require_record_payload(&entry)?; self.refuse_unread_slots(&entry.r#type, &entry.key)?; self.refuse_claimed_id(&entry)?; @@ -4328,6 +4318,73 @@ fn restamped_over(entry: &CultCacheEnvelope, held: &[CultCacheEnvelope]) -> Resu Ok(entry) } +/// Whether `text` is a `stored_at` stamp: the one shape pinned in `tests/vectors/stored-at-stamp/`. An RFC 3339 date-time, +/// `YYYY-MM-DDThh:mm:ss`, an optional fraction of 1 to 9 digits, then `Z` or `+hh:mm` / `-hh:mm` (hours 00-23, minutes 00-59); +/// uppercase `T` and `Z`, ASCII digits, no surrounding whitespace, a real calendar date, seconds 00-59, a local year of 0001-9999, and an instant (the +/// offset applied) within years 0001-9999. Checked by hand, not by `chrono`'s RFC 3339 reader, which takes other shapes +/// (lowercase `t`, a space, a leap second). A peer's `storedAt` is stored as it arrives, so one that is not a stamp is refused. +pub fn is_stored_at_stamp(text: &str) -> bool { + let b = text.as_bytes(); + let digits = |from: usize, count: usize| -> Option { + let part = b.get(from..from + count)?; + part.iter().all(u8::is_ascii_digit).then(|| part.iter().fold(0, |n, d| n * 10 + u32::from(d - b'0'))) + }; + let at = |index: usize, byte: u8| b.get(index) == Some(&byte); + let (Some(year), Some(month), Some(day), Some(hour), Some(minute), Some(second)) = + (digits(0, 4), digits(5, 2), digits(8, 2), digits(11, 2), digits(14, 2), digits(17, 2)) + else { + return false; + }; + if !(at(4, b'-') && at(7, b'-') && at(10, b'T') && at(13, b':') && at(16, b':')) { + return false; + } + let mut next = 19; + if at(next, b'.') { + let fraction = b[next + 1..].iter().take_while(|byte| byte.is_ascii_digit()).count(); + if !(1..=9).contains(&fraction) { + return false; + } + next += 1 + fraction; + } + let offset_seconds = if b.len() == next + 1 && at(next, b'Z') { + 0 + } else if b.len() == next + 6 && (at(next, b'+') || at(next, b'-')) && at(next + 3, b':') { + let (Some(hours), Some(minutes)) = (digits(next + 1, 2), digits(next + 4, 2)) else { + return false; + }; + if hours > 23 || minutes > 59 { + return false; + } + let magnitude = i64::from(hours * 3600 + minutes * 60); + if at(next, b'-') { -magnitude } else { magnitude } + } else { + return false; + }; + if second > 59 || year < 1 { + return false; + } + let Some(local) = chrono::NaiveDate::from_ymd_opt(year as i32, month, day) + .and_then(|date| date.and_hms_opt(hour, minute, second)) + else { + return false; + }; + use chrono::Datelike; + local + .checked_sub_signed(chrono::TimeDelta::seconds(offset_seconds)) + .is_some_and(|utc| (1..=9999).contains(&utc.year())) +} + +/// A raw envelope is stored under the stamp it carries, so one that is no stamp is refused and nothing is minted for it. +fn require_stored_at_stamp(entry: &CultCacheEnvelope) -> Result<()> { + if is_stored_at_stamp(&entry.stored_at) { + return Ok(()); + } + Err(anyhow!( + "CultCache envelope stored_at for type {:?} must be an RFC 3339 stamp", + entry.r#type + )) +} + fn now_utc_second() -> String { chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true) } @@ -6895,7 +6952,7 @@ mod tests { let (mut empty_stored_at, _) = cache.prepare_entry("n", &Note { title: "t".to_string(), body: "b".to_string() })?; empty_stored_at.stored_at = " ".to_string(); let error = cache.put_prepared_batch(vec![empty_stored_at]).unwrap_err(); - assert!(error.to_string().contains("stored_at for type \"note\" must be non-empty"), "{error}"); + assert!(error.to_string().contains("stored_at for type \"note\" must be an RFC 3339 stamp"), "{error}"); assert_eq!(cache.snapshot(), before); assert_eq!(fs::read(&store_path)?, store_bytes); diff --git a/packages/cultcache-rs/tests/stored_at_stamp_vector.rs b/packages/cultcache-rs/tests/stored_at_stamp_vector.rs new file mode 100644 index 00000000..d1ab7f9a --- /dev/null +++ b/packages/cultcache-rs/tests/stored_at_stamp_vector.rs @@ -0,0 +1,58 @@ +//! `tests/vectors/stored-at-stamp`: the one shape a peer's `storedAt` must parse as. Every row is walked against +//! `is_stored_at_stamp`, and each row that is no stamp against the cache's two raw doors, `put_raw_envelope` and +//! `put_prepared_batch`: refused, nothing stored or minted, the store still writable after +//! (`variants:ruling:raw-put-without-stamp-refused`). + +use std::path::PathBuf; + +use cultcache_rs::{CacheBackingStore, CultCache, DatabaseEntry, SingleFileMessagePackBackingStore, is_stored_at_stamp}; + +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "tests.stamp_note", schema = "tests.stamp_note")] +struct Note { + #[cultcache(key = 0)] + text: String, +} + +fn rows() -> Vec<(String, bool)> { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../tests/vectors/stored-at-stamp/stamps.msgpack"); + rmp_serde::from_slice(&std::fs::read(path).unwrap()).unwrap() +} + +#[test] +fn the_stamp_check_agrees_with_every_vector_row() { + let rows = rows(); + assert!(rows.len() > 30, "the vector was not read"); + for (text, parses) in rows { + assert_eq!(is_stored_at_stamp(&text), parses, "{text:?}"); + } +} + +#[test] +fn a_raw_envelope_that_is_no_stamp_is_refused_and_nothing_is_stored() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("stamps.cc"); + let mut cache = CultCache::new(); + cache.register_entry_type::().unwrap(); + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap(); + cache.pull_all_backing_stores().unwrap(); + for (text, parses) in rows() { + if parses { + continue; + } + let (mut entry, _) = cache.prepare_entry("a", &Note { text: "peer".into() }).unwrap(); + entry.stored_at = text.clone(); + let error = cache.put_raw_envelope(entry.clone()).unwrap_err(); + assert!(format!("{error:#}").contains("stored_at"), "{text:?}: {error:#}"); + let error = cache.put_prepared_batch(vec![entry]).unwrap_err(); + assert!(format!("{error:#}").contains("stored_at"), "{text:?}: {error:#}"); + assert!(cache.get::("a").unwrap().is_none(), "{text:?} was stored"); + assert!(!path.exists() || SingleFileMessagePackBackingStore::new(&path).pull_all().unwrap().is_empty(), "{text:?} reached the file"); + } + // Refused writes left the store writable: a stamped one lands under the stamp it carries. + let (mut entry, _) = cache.prepare_entry("b", &Note { text: "stamped".into() }).unwrap(); + entry.stored_at = "2026-11-07T00:00:00Z".into(); + cache.put_raw_envelope(entry).unwrap(); + assert_eq!(cache.get::("b").unwrap().unwrap().text, "stamped"); + assert!(cache.get::("a").unwrap().is_none()); +} diff --git a/packages/cultcache-ts/CHANGELOG.md b/packages/cultcache-ts/CHANGELOG.md index b5ab90bd..b41455a3 100644 --- a/packages/cultcache-ts/CHANGELOG.md +++ b/packages/cultcache-ts/CHANGELOG.md @@ -5,8 +5,11 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following ## [Unreleased] +## [0.16.0] - 2026-11-14 + ### Added +- `isStoredAtStamp(text)` (package and browser entries): whether a `storedAt` is a stamp, the one shape pinned in `tests/vectors/stored-at-stamp/` (an RFC 3339 date-time with `T`, an optional 1 to 9 digit fraction, `Z` or `+hh:mm`/`-hh:mm`, a real calendar date, seconds 00-59, years 0001-9999). cultnet-ts and cultmesh-ts judge a peer's put by it (variants:ruling:raw-put-without-stamp-refused). - `CacheBackingStore` gains the optional `read(key)` (the record at the key with its content version, from one read) and `commitIf(entry, expectedVersion)` (write only if the version still holds, under the store's write lock; returns the storedAt written). The single-file store implements both. The content version is the lowercase hex SHA-256 of the record's persisted encoding `[key, schemaId, storedAt, payload]`, equal to the C# reference's on `tests/vectors/content-version`. A store without them still works for everything except `update`. - `CultCacheUpdateResult` and `CultCacheUpdateOptions` (`maxAttempts`, default 5), exported from the package entry and the browser entry. diff --git a/packages/cultcache-ts/package.json b/packages/cultcache-ts/package.json index b6803979..f444f51a 100644 --- a/packages/cultcache-ts/package.json +++ b/packages/cultcache-ts/package.json @@ -1,6 +1,6 @@ { "name": "@gamecult/cultcache-ts", - "version": "0.15.0", + "version": "0.16.0", "description": "CultCache-style typed persistence for TypeScript with schema-backed polymorphic MessagePack storage.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/cultcache-ts/src/browser.ts b/packages/cultcache-ts/src/browser.ts index 05a5ecd6..529bc53b 100644 --- a/packages/cultcache-ts/src/browser.ts +++ b/packages/cultcache-ts/src/browser.ts @@ -16,6 +16,7 @@ export { } from "./document"; export { CultCachePayloadDepthError, MAX_PAYLOAD_DEPTH } from "./payload-depth"; export type { DocumentPayload } from "./document"; +export { isStoredAtStamp } from "./stored-at"; export { SchemaConflictError, StoreUnreadableError } from "./store-format"; export type { AnyCultCacheDocumentDefinition, diff --git a/packages/cultcache-ts/src/stored-at.ts b/packages/cultcache-ts/src/stored-at.ts new file mode 100644 index 00000000..d630a411 --- /dev/null +++ b/packages/cultcache-ts/src/stored-at.ts @@ -0,0 +1,33 @@ +// The one shape a `storedAt` stamp has (tests/vectors/stored-at-stamp/README.md; reference `parse_stored_at` in cultcache-py): an +// RFC 3339 date-time `YYYY-MM-DDThh:mm:ss`, an optional fraction of 1 to 9 digits, and `Z` or `+hh:mm` / `-hh:mm` (hours 00-23, +// minutes 00-59). A real calendar date, seconds 00-59, and an instant within years 0001-9999. Checked here, not left to +// `Date.parse`, which accepts shapes this one does not and whose accepted set differs by engine. +const STAMP = /^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})(?:\.[0-9]{1,9})?(?:Z|([+-])([01][0-9]|2[0-3]):([0-5][0-9]))$/; + +const MS_PER_MINUTE = 60_000; + +/** True when `text` is a string that is a stamp. Anything else (missing, empty, wrong type, other shapes) is not. */ +export function isStoredAtStamp(text: unknown): text is string { + if (typeof text !== "string") return false; + const shape = STAMP.exec(text); + if (shape === null) return false; + const [year, month, day, hour, minute, second] = shape.slice(1, 7).map(Number) as [number, number, number, number, number, number]; + const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; + const daysInMonth = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1]; + if (year < 1 || month < 1 || month > 12 || day < 1 || day > (daysInMonth as number) || hour > 23 || minute > 59 || second > 59) { + return false; + } + // The UTC instant must stay within years 0001-9999, so an offset can push a boundary date out of range. + const local = new Date(0); + local.setUTCFullYear(year, month - 1, day); + local.setUTCHours(hour, minute, second, 0); + const offsetMinutes = shape[7] === undefined ? 0 : (shape[7] === "-" ? -1 : 1) * (Number(shape[8]) * 60 + Number(shape[9])); + const utcMs = local.getTime() - offsetMinutes * MS_PER_MINUTE; + const first = new Date(0); + first.setUTCFullYear(1, 0, 1); + first.setUTCHours(0, 0, 0, 0); + const last = new Date(0); + last.setUTCFullYear(9999, 11, 31); + last.setUTCHours(23, 59, 59, 999); + return utcMs >= first.getTime() && utcMs <= last.getTime(); +} diff --git a/packages/cultcache-ts/test/stored-at-stamp.test.ts b/packages/cultcache-ts/test/stored-at-stamp.test.ts new file mode 100644 index 00000000..d1ed62d2 --- /dev/null +++ b/packages/cultcache-ts/test/stored-at-stamp.test.ts @@ -0,0 +1,20 @@ +// isStoredAtStamp agrees with every row of tests/vectors/stored-at-stamp/stamps.msgpack, the one shape a peer's storedAt must parse as. +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import test from "node:test"; +import { decode } from "@msgpack/msgpack"; +import { isStoredAtStamp } from "../src"; + +// dist-test/test -> the repository root. +export const STAMP_VECTOR = join(__dirname, "..", "..", "..", "..", "tests", "vectors", "stored-at-stamp", "stamps.msgpack"); + +test("isStoredAtStamp answers every row of the stored-at-stamp vector", () => { + const rows = decode(readFileSync(STAMP_VECTOR)) as [string, boolean][]; + assert.ok(rows.length > 20, "the vector was read"); + for (const [text, parses] of rows) assert.equal(isStoredAtStamp(text), parses, JSON.stringify(text)); +}); + +test("a stamp is a string: anything else is not one", () => { + for (const value of [undefined, null, 0, 1_700_000_000_000, true, {}, [], new Date()]) assert.equal(isStoredAtStamp(value), false); +}); diff --git a/packages/cultmesh-browser/package.json b/packages/cultmesh-browser/package.json index ab770f8c..5e72bfbc 100644 --- a/packages/cultmesh-browser/package.json +++ b/packages/cultmesh-browser/package.json @@ -41,7 +41,7 @@ "node": ">=20" }, "dependencies": { - "@gamecult/cultcache-ts": "^0.15.0", + "@gamecult/cultcache-ts": "^0.16.0", "@gamecult/cultnet-ts": "^0.4.0" }, "devDependencies": { diff --git a/packages/cultmesh-py/src/cultmesh_py/node.py b/packages/cultmesh-py/src/cultmesh_py/node.py index ff6ef8f2..eb439683 100644 --- a/packages/cultmesh-py/src/cultmesh_py/node.py +++ b/packages/cultmesh-py/src/cultmesh_py/node.py @@ -12,7 +12,7 @@ from typing import Any, Callable from cultcache_py import CultCache, CultCacheEnvelope, SingleFileMessagePackBackingStore from cultcache_py.cache import CultCacheError, index_text from cultcache_py.documents import DocumentDefinition, extract_value -from cultcache_py.stores import parse_stored_at +from cultnet_py.replication import raw_record_to_envelope from cultnet_py import ( CultNetAppliedRecord, CultNetFileShardMutationLogStore, @@ -724,19 +724,8 @@ class CultMeshDatabase: document = self._raw_put_document(message) if document is None: return None - document_record = message["document"] - # A peer's put is stored under the stamp it carries, so one that is no stamp is refused: nothing is minted for it. - if parse_stored_at(document_record.get("storedAt")) is None: - raise ValueError("A raw put must carry a storedAt stamp.") - # One id: the local schema id, under which the cache stores the record. - return document, CultCacheEnvelope( - key=str(document_record["recordKey"]), - type=document.type, - schema_id=document.catalog_entry().schema_id, - payload=bytes(document_record["payload"]), - stored_at=document_record["storedAt"], - catalog_entry=document.catalog_entry(), - ) + # One id: the local schema id, under which the cache stores the record. The stamp is judged where a record becomes an envelope. + return document, raw_record_to_envelope(document, document.catalog_entry().schema_id, message["document"]) @staticmethod def _served_record(envelope: CultCacheEnvelope) -> CultNetRawDocumentRecord: diff --git a/packages/cultmesh-py/tests/test_stampless_replication.py b/packages/cultmesh-py/tests/test_stampless_replication.py new file mode 100644 index 00000000..6acb647a --- /dev/null +++ b/packages/cultmesh-py/tests/test_stampless_replication.py @@ -0,0 +1,212 @@ +"""A record a peer replicates to this node is stored under the stamp it carries, so one with no stamp is refused where it becomes an envelope. + +The stamp shape is tests/vectors/stored-at-stamp/stamps.msgpack, held to the same answers as the raw put of the server +(variants:ruling:raw-put-without-stamp-refused). A snapshot response is refused whole with ValueError before anything is stored (as a record of an unregistered schema is refused whole). A shard log is +refused whole too: every put it would apply is judged for its stamp before the first entry is stored (a put of an unregistered schema is skipped, as ever, and is not judged). The refusal of a put is +message_invalid (field `document`), tested in test_remote_write_authority.py. +""" + +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path +from typing import Any + +import msgpack +from cultcache_py import define_database_entry_type +from cultcache_py.stores import parse_stored_at +from cultmesh_py import CultMeshGrantAllWrites, CultMeshServerPeer, create_node +from cultmesh_py.client import CultMeshDocumentSubscription +from cultmesh_py.server import CultMeshLocalServer +from cultnet_py import CultNetDatabaseChange, document_put_raw + +SCHEMA_ID = "sha256:stampless-note" +SHARD_ID = "stampless-shard" +PEER = CultMeshServerPeer("127.0.0.1:50000", "tcp") +VECTOR = Path(__file__).resolve().parents[3] / "tests/vectors/stored-at-stamp/stamps.msgpack" +ROWS: list[tuple[str, bool]] = [(text, parses) for text, parses in msgpack.unpackb(VECTOR.read_bytes(), raw=False)] +# The vector's texts, plus the shapes a record can lack a stamp in: the field missing, and not a string at all. +REFUSED: list[tuple[str, Any]] = [(f"vector {text!r}", text) for text, parses in ROWS if not parses] + [ + ("missing", None), + ("not a string", 12345), +] + +NOTE = define_database_entry_type( + "tests.stampless_note", + [("schema_version", 0), ("text", 1)], + schema_id=SCHEMA_ID, + schema_name="tests.stampless_note", + schema_version="tests.stampless_note.v1", +) + + +def record_of(key: str, text: str, stamp: Any = "2026-11-07T00:00:00Z") -> dict[str, Any]: + record = { + "schemaId": SCHEMA_ID, + "recordKey": key, + "payloadEncoding": "messagepack", + "payload": NOTE.encode_payload({"schema_version": "tests.stampless_note.v1", "text": text}), + } + if stamp is not None: + record["storedAt"] = stamp + return record + + +def snapshot_of(*records: dict[str, Any]) -> dict[str, Any]: + return {"schemaVersion": "cultnet.snapshot_response_raw.v0", "messageId": "snapshot", "documents": list(records)} + + +def shard_log_of(*records: dict[str, Any]) -> dict[str, Any]: + entries = [ + { + "sequence": index + 1, + "committedAt": "2026-11-07T00:00:00Z", + "changeKind": "added", + "put": document_put_raw( + message_id=f"put-{index}", key=record["recordKey"], schema_id=SCHEMA_ID, stored_at="2026-11-07T00:00:00Z", + payload=record["payload"], shard_id=SHARD_ID, shard_epoch=1, + ).to_wire() | {"document": record}, + } + for index, record in enumerate(records) + ] + return { + "schemaVersion": "cultnet.shard_log_response.v0", "messageId": "log", "shardId": SHARD_ID, "shardEpoch": 1, + "entries": entries, "resyncRequired": False, + } + + +class StampVectorTests(unittest.TestCase): + def test_python_parses_every_row_of_the_vector_as_the_vector_says(self) -> None: + self.assertGreater(len(ROWS), 30) + for text, parses in ROWS: + with self.subTest(text=text): + self.assertEqual(parse_stored_at(text) is not None, parses) + + def test_every_vector_row_through_the_server_put_handler_is_stored_or_refused_as_the_vector_says(self) -> None: + with tempfile.TemporaryDirectory() as directory: + node = create_node(Path(directory) / "node.cc", runtime_id="vector-server") + node.database.register_document(NOTE) + server = CultMeshLocalServer(node=node, port=0, write_authority=CultMeshGrantAllWrites()) + sized = CultMeshLocalServer(node=node, port=0, write_authority=CultMeshGrantAllWrites(), max_snapshot_bytes=10**6) + for index, (text, parses) in enumerate(ROWS): + for label, handler, over_rudp in (("plain", server, False), ("sized", sized, False), ("rudp", server, True)): + with self.subTest(text=text, path=label): + key = f"vector:{index}:{label}" + message = document_put_raw( + message_id="wire", key=key, schema_id=SCHEMA_ID, stored_at=text, + payload=record_of(key, "peer")["payload"], shard_id=SHARD_ID, shard_epoch=1, + ).to_wire() + replies = handler._handle_connection_message(message, {}, over_rudp=over_rudp, peer=PEER) + if parses: + self.assertEqual(replies, []) + self.assertEqual(node.cache.get(NOTE, key)["text"], "peer") + else: + self.assertEqual([(r["code"], r["details"]["field"]) for r in replies], [("message_invalid", "document")]) + self.assertIsNone(node.cache.get(NOTE, key)) + + +class StamplessSnapshotTests(unittest.TestCase): + def build(self, directory: str) -> Any: + node = create_node(Path(directory) / "node.cc", runtime_id="stampless-client") + node.database.register_document(NOTE) + node.database.pull() + return node + + def test_a_snapshot_with_a_stampless_record_is_refused_whole_and_the_store_survives_a_reopen(self) -> None: + for label, stamp in REFUSED: + with self.subTest(label), tempfile.TemporaryDirectory() as directory: + node = self.build(directory) + good, bad = record_of("note:good", "good"), record_of("note:bad", "bad", stamp) + for order in ((good, bad), (bad, good)): + with self.assertRaises(ValueError): + node.database.apply_snapshot_response(snapshot_of(*order)) + self.assertIsNone(node.cache.get(NOTE, "note:good"), "a refused snapshot stores none of its records") + self.assertIsNone(node.cache.get(NOTE, "note:bad")) + # The store file reads back, and is still writable by a snapshot of stamped records. + reopened = self.build(directory) + self.assertIsNone(reopened.cache.get(NOTE, "note:bad")) + applied = reopened.database.apply_snapshot_response(snapshot_of(good)) + self.assertEqual([a.record_key for a in applied], ["note:good"]) + self.assertEqual(self.build(directory).cache.get(NOTE, "note:good")["text"], "good") + + def test_a_snapshot_of_every_stamped_vector_row_is_stored(self) -> None: + with tempfile.TemporaryDirectory() as directory: + node = self.build(directory) + rows = [(index, text) for index, (text, parses) in enumerate(ROWS) if parses] + node.database.apply_snapshot_response(snapshot_of(*[record_of(f"stamped:{i}", "ok", text) for i, text in rows])) + self.assertEqual(len(self.build(directory).cache.get_all(NOTE)), len(rows)) + for index, _ in rows: + self.assertEqual(node.cache.get(NOTE, f"stamped:{index}")["text"], "ok") + + +class StamplessShardLogTests(unittest.TestCase): + def test_a_shard_log_put_with_no_stamp_is_refused_and_the_store_stays_writable(self) -> None: + for label, stamp in REFUSED: + with self.subTest(label), tempfile.TemporaryDirectory() as directory: + node = create_node(Path(directory) / "node.cc", runtime_id="stampless-log") + node.database.register_document(NOTE) + with self.assertRaises(ValueError): + node.database.apply_shard_log_response(shard_log_of(record_of("note:bad", "bad", stamp))) + self.assertIsNone(node.cache.get(NOTE, "note:bad")) + reopened = create_node(Path(directory) / "node.cc", runtime_id="stampless-log") + reopened.database.register_document(NOTE) + reopened.database.pull() + reopened.database.apply_shard_log_response(shard_log_of(record_of("note:good", "good"))) + self.assertEqual(reopened.cache.get(NOTE, "note:good")["text"], "good") + again = create_node(Path(directory) / "node.cc", runtime_id="stampless-log") + again.database.register_document(NOTE) + again.database.pull() + self.assertEqual(again.cache.get(NOTE, "note:good")["text"], "good") + + + def test_a_shard_log_with_a_bad_entry_in_the_middle_stores_nothing_and_tells_no_watcher(self) -> None: + for label, stamp in REFUSED: + with self.subTest(label), tempfile.TemporaryDirectory() as directory: + node = create_node(Path(directory) / "node.cc", runtime_id="stampless-multi") + node.database.register_document(NOTE) + seen: list[Any] = [] + node.database.watch(seen.append) + log = shard_log_of(record_of("note:a", "a"), record_of("note:b", "b", stamp), record_of("note:c", "c")) + with self.assertRaises(ValueError): + node.database.apply_shard_log_response(log) + for key in ("note:a", "note:b", "note:c"): + self.assertIsNone(node.cache.get(NOTE, key), f"{key} was not stored") + self.assertEqual(seen, [], "no watcher heard of a refused log") + node.database.apply_shard_log_response(shard_log_of(record_of("note:a", "a"), record_of("note:c", "c"))) + self.assertEqual([node.cache.get(NOTE, k)["text"] for k in ("note:a", "note:c")], ["a", "c"]) + self.assertEqual(len(seen), 2, "a later good log applies and is heard") + + +class _StubSubscription: + def __init__(self, change: CultNetDatabaseChange) -> None: + self._change = change + + def read_next_change(self) -> CultNetDatabaseChange: + return self._change + + +class StamplessClientReplayTests(unittest.TestCase): + def test_a_subscribed_put_with_no_stamp_is_refused_by_read_next_change_and_nothing_is_stored(self) -> None: + for label, stamp in REFUSED: + with self.subTest(label), tempfile.TemporaryDirectory() as directory: + node = create_node(Path(directory) / "node.cc", runtime_id="stampless-subscriber") + node.database.register_document(NOTE) + subscription = CultMeshDocumentSubscription(client=None, database=node.database, document=NOTE, key="note:bad") # type: ignore[arg-type] + + def deliver(record: dict[str, Any]) -> Any: + message = { + "schemaVersion": "cultnet.database_change_raw.v0", "messageId": "change", "changeKind": "added", + "schemaId": SCHEMA_ID, "recordKey": record["recordKey"], "document": record, + } + subscription._subscription = _StubSubscription(CultNetDatabaseChange.from_wire(message)) # type: ignore[assignment] + return subscription.read_next_change() + + with self.assertRaises(ValueError): + deliver(record_of("note:bad", "bad", stamp)) + self.assertIsNone(node.cache.get(NOTE, "note:bad")) + self.assertEqual(deliver(record_of("note:bad", "stamped")).value["text"], "stamped") + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/cultmesh-rs/src/lib.rs b/packages/cultmesh-rs/src/lib.rs index b1ee613b..e3132ece 100644 --- a/packages/cultmesh-rs/src/lib.rs +++ b/packages/cultmesh-rs/src/lib.rs @@ -1301,6 +1301,30 @@ mod tests { Ok(()) } + /// The node's store is the second owner of the stamp rule: a snapshot document whose `storedAt` is no stamp refuses + /// the whole pull, nothing is minted or stored, and the node stays writable. + #[test] + fn a_snapshot_document_whose_stored_at_is_no_stamp_refuses_the_pull_and_stores_nothing() -> Result<()> { + let temp = tempfile::tempdir()?; + let path = temp.path().join("m.cc"); + let mut node = CultMesh::create_node(&path, TestDocuments, Default::default())?; + let plain = |body: &str| rmp_serde::to_vec(&(body,)); + for bad in ["", "yesterday", "2026-01-01T00:00:00", "2026-01-01t00:00:00z", "2026-01-01T00:00:60Z"] { + let mut stampless = raw_note("bad", plain("bad")?); + stampless.stored_at = bad.to_string(); + let error = node + .apply_raw_snapshot_documents(vec![raw_note("good", plain("good")?), stampless]) + .unwrap_err(); + assert!(format!("{error:#}").contains("stored_at"), "{bad:?}: {error:#}"); + assert!(node.get::("good")?.is_none() && node.get::("bad")?.is_none(), "{bad:?}"); + } + assert_eq!(node.apply_raw_snapshot_documents(vec![raw_note("after", plain("after")?)])?, 1); + let reopened = CultMesh::create_node(&path, TestDocuments, Default::default())?; + assert_eq!(reopened.get_required::("after")?.body, "after"); + assert!(reopened.get::("good")?.is_none()); + Ok(()) + } + #[test] fn a_snapshot_applied_to_a_stale_node_keeps_the_records_another_writer_added() -> Result<()> { let temp = tempfile::tempdir()?; diff --git a/packages/cultmesh-rs/src/rudp_document_server.rs b/packages/cultmesh-rs/src/rudp_document_server.rs index 9aae5be9..1c440a14 100644 --- a/packages/cultmesh-rs/src/rudp_document_server.rs +++ b/packages/cultmesh-rs/src/rudp_document_server.rs @@ -69,7 +69,8 @@ pub struct CultMeshRudpSnapshotQuery { pub trait CultMeshRudpRawDocumentSink { /// Whether this put may be written, decided without writing it. A receipt-capable session asks it only /// the first time it sees a message id; a resend is answered from what the session remembers - /// (write-receipt.md, rule 5). The default admits everything and leaves the decision to + /// (write-receipt.md, rule 5). A legacy session asks it only for a put whose `storedAt` is no stamp, so the + /// authority's refusal comes before the stamp's. The default admits everything and leaves the decision to /// `accept_raw_document`. fn authorize_raw_document(&mut self, _receipt: &CultMeshRudpRawDocumentReceipt) -> Result<()> { Ok(()) @@ -293,6 +294,9 @@ pub enum CultMeshRudpRejectionReason { DocumentMissing, /// A receipt-capable session's put or delete has a null or missing `schemaId`. SchemaIdMissing, + /// A put's `storedAt` is not a stamp (`cultcache_rs::is_stored_at_stamp`). It is judged after the sink's + /// authorization and before the document is measured or the sink commits it: nothing is minted or stored. + StoredAtNotStamp, } impl std::fmt::Display for CultMeshRudpRejectionReason { @@ -307,6 +311,7 @@ impl std::fmt::Display for CultMeshRudpRejectionReason { Self::ChannelNotReliable => f.write_str("a receipt session's write arrived on an unreliable channel"), Self::DocumentMissing => f.write_str("the write has no document"), Self::SchemaIdMissing => f.write_str("the write has no schemaId"), + Self::StoredAtNotStamp => f.write_str("the document's storedAt is not a stamp"), Self::DocumentUnservable { response_bytes, max_snapshot_response_bytes, @@ -347,7 +352,7 @@ impl std::fmt::Display for CultMeshRudpRejectionReason { /// The texts a refused peer is sent, one per rejection variant. They are fixed: /// a sink's or source's error text can quote what a peer sent, so it stays with /// the caller that receives the rejection and never reaches the wire. -pub(crate) const REFUSAL_TEXTS: [&str; 17] = [ +pub(crate) const REFUSAL_TEXTS: [&str; 18] = [ "the catalog refused the document", "the document can never be served", "the snapshot source failed", @@ -365,6 +370,7 @@ pub(crate) const REFUSAL_TEXTS: [&str; 17] = [ "receipt sessions require the reliable channel", "document is required", "schemaId is required", + "document must carry a storedAt stamp", ]; impl CultMeshRudpRejectionReason { @@ -393,7 +399,8 @@ impl CultMeshRudpRejectionReason { 13 => Self::MessageIdTooLong, 14 => Self::ChannelNotReliable, 15 => Self::DocumentMissing, - _ => Self::SchemaIdMissing, + 16 => Self::SchemaIdMissing, + _ => Self::StoredAtNotStamp, } } @@ -416,6 +423,7 @@ impl CultMeshRudpRejectionReason { Self::ChannelNotReliable => 14, Self::DocumentMissing => 15, Self::SchemaIdMissing => 16, + Self::StoredAtNotStamp => 17, } } } @@ -498,6 +506,7 @@ impl CultMeshRudpApplicationRejection { (CultMeshRudpRejectionReason::MessageIdTooLong, _) => message_invalid_message("messageId", text), (CultMeshRudpRejectionReason::DocumentMissing, _) => message_invalid_message("document", text), (CultMeshRudpRejectionReason::SchemaIdMissing, _) => message_invalid_message("schemaId", text), + (CultMeshRudpRejectionReason::StoredAtNotStamp, _) => message_invalid_message("document", text), (CultMeshRudpRejectionReason::ChannelNotReliable, _) => message_invalid_message("channel", text), (CultMeshRudpRejectionReason::WriteLimitReached, _) => CultNetMessage::Error { error: format!("limit_reached: {text}"), @@ -1601,11 +1610,19 @@ where receipt: CultMeshRudpRawDocumentReceipt, authorize: bool, ) -> std::result::Result<(), CultMeshRudpRejectionReason> { - if authorize { + let stamped = cultcache_rs::is_stored_at_stamp(&receipt.document.stored_at); + // The authority speaks first. A legacy session's sink decides at `accept_raw_document`, so it is asked here + // only when the stamp is bad: an ungranted peer is refused `write_not_authorized`, not told its stamp is wrong. + if authorize || !stamped { if let Err(error) = self.sink.authorize_raw_document(&receipt) { return Err(CultMeshRudpRejectionReason::SinkRefused(format!("{error:#}"))); } } + // A peer's put is stored under the stamp it carries, so one that is no stamp is refused before anything is + // measured, minted or handed to the sink. + if !stamped { + return Err(CultMeshRudpRejectionReason::StoredAtNotStamp); + } let document = &receipt.document; // Admit only what some snapshot request can return: the smallest // response that could carry this document is the record the snapshot diff --git a/packages/cultmesh-rs/tests/rudp_document_server.rs b/packages/cultmesh-rs/tests/rudp_document_server.rs index 60596ee5..3f4d391a 100644 --- a/packages/cultmesh-rs/tests/rudp_document_server.rs +++ b/packages/cultmesh-rs/tests/rudp_document_server.rs @@ -3546,6 +3546,10 @@ fn blank_means_exactly_the_unicode_white_space_code_points_in_every_field() -> R for (index, (hex, blank)) in space.iter().map(|h| (h, true)).chain(not_space.iter().map(|h| (h, false))).enumerate() { let hex = hex.as_str().unwrap(); for (field_index, field) in fields.iter().enumerate() { + // A storedAt must be a stamp: a non-blank one that is no stamp is not a name (write-receipt.md, Required fields). + if !blank && *field == "storedAt" { + continue; + } // A session that is sent refusals is allowed to end, so each code point and field gets its own. let sink = Sink::default(); let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; @@ -3626,3 +3630,177 @@ fn an_unreadable_frame_on_a_receipt_connection_is_sent_exactly_the_fixed_text() ); Ok(()) } + +// ---- A peer's `storedAt` must be a stamp (variants:ruling:raw-put-without-stamp-refused) ---- +// +// `tests/vectors/stored-at-stamp/stamps.msgpack`: `[text, parses]` rows. Every row that is no stamp is refused and +// nothing reaches the sink. A blank text names nothing, so it is refused as a frame that does not read (field +// "message", write-receipt.md, Required fields); any other text that is no stamp is refused field "document". + +fn stamp_rows() -> Vec<(String, bool)> { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/vectors/stored-at-stamp/stamps.msgpack"); + rmp_serde::from_slice(&std::fs::read(path).unwrap()).unwrap() +} + +fn is_blank(text: &str) -> bool { + text.chars().all(char::is_whitespace) +} + +/// A put of `key` whose `storedAt` is `stamp` as it would arrive: a blank one is swapped into the bytes, since the +/// encoder refuses it. +fn put_stamped_bytes(id: &str, key: &str, payload: Vec, stamp: &str) -> Result> { + let mut record = document(key, payload); + record.stored_at = if is_blank(stamp) { "zz-stamp-zz".into() } else { stamp.into() }; + let bytes = encode_cultnet_message_to_vec( + &CultNetMessage::DocumentPutRaw { message_id: id.into(), document: record }, + CultNetWireContract::CultNetSchemaV0, + )?; + Ok(if is_blank(stamp) { swap_fixstr(&bytes, "zz-stamp-zz", stamp) } else { bytes }) +} + +#[test] +fn a_receipt_session_put_that_is_no_stamp_is_refused_for_every_vector_row_and_the_store_stays_writable() -> Result<()> { + let rows = stamp_rows(); + assert!(rows.len() > 30); + let sink = Sink::default(); + let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; + let mut peer = client(server.local_addr()?, 700)?; + connect(&mut server, &mut [&mut peer])?; + send_reliable(&mut peer, &receipt_hello())?; + assert_eq!(frames_after(&mut server, &mut peer, 1)?, vec![server_hello()]); + let mut expected = Vec::new(); + let mut answers = Vec::new(); + for (index, (text, parses)) in rows.iter().enumerate() { + let id = format!("r{index}"); + peer.send_reliable("schema", put_stamped_bytes(&id, &format!("note:{index}"), vec![1, 2, 3], text)?)?; + expected.push((id, text.clone(), *parses)); + // One write in flight at a time: the answer arrives before the next row is sent. + answers.extend(frames_after(&mut server, &mut peer, 1)?); + } + peer.send_reliable("schema", valid_put("after"))?; + answers.extend(frames_after(&mut server, &mut peer, 1)?); + for (answer, (id, text, parses)) in answers.iter().zip(&expected) { + if *parses { + assert_eq!(*answer, admitted(id, &id.replace('r', "note:")), "{text:?}"); + continue; + } + assert_eq!(refusal_of(answer), (Some(CultNetErrorCode::MessageInvalid), Some(id.clone())), "{text:?}"); + let field = if is_blank(text) { "message" } else { "document" }; + assert_eq!(error_text_and_field(answer).1, Some(field.into()), "{text:?}"); + } + // Every stamp row was stored exactly as sent; no refused row was stored; the put after them all was. + let state = sink.0.lock().unwrap(); + let stored: Vec<&str> = state.receipts.iter().map(|receipt| receipt.document.stored_at.as_str()).collect(); + let stamps: Vec<&str> = rows.iter().filter(|(_, parses)| *parses).map(|(text, _)| text.as_str()).collect(); + assert_eq!(&stored[..stamps.len()], &stamps[..]); + assert_eq!(stored.len(), stamps.len() + 1, "only the stamped puts and the final put were stored"); + assert_eq!(answers.last(), Some(&admitted("put-after", "after"))); + Ok(()) +} + +#[test] +fn a_put_with_no_storedat_key_is_refused_and_a_resend_replays_the_refusal() -> Result<()> { + let sink = Sink::default(); + let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; + let mut peer = client(server.local_addr()?, 701)?; + connect(&mut server, &mut [&mut peer])?; + send_reliable(&mut peer, &receipt_hello())?; + let missing = swap_fixstr(&valid_put("a"), "storedAt", "storedAx"); + peer.send_reliable("schema", missing)?; + // The same garbage stamp twice under one id: the refusal is remembered (by index) and replays as it was. + peer.send_reliable("schema", put_stamped_bytes("g1", "note:g", vec![1], "yesterday")?)?; + peer.send_reliable("schema", put_stamped_bytes("g1", "note:g", vec![1], "yesterday")?)?; + let answers = answers_after(&mut server, &mut peer, 3)?; + assert_eq!(error_text_and_field(&answers[0]).1, Some("message".into())); + assert_eq!(refusal_of(&answers[1]), (Some(CultNetErrorCode::MessageInvalid), Some("g1".into()))); + assert_eq!(error_text_and_field(&answers[1]).1, Some("document".into())); + assert_eq!(answers[2], answers[1]); + assert!(sink.0.lock().unwrap().receipts.is_empty()); + Ok(()) +} + +#[test] +fn a_legacy_session_put_that_is_no_stamp_is_refused_for_every_vector_row_and_nothing_is_stored() -> Result<()> { + let sink = Sink::default(); + let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; + let mut connection_id = 800; + for (text, parses) in stamp_rows() { + if parses { + continue; + } + connection_id += 1; + let mut peer = client(server.local_addr()?, connection_id)?; + connect(&mut server, &mut [&mut peer])?; + let receipt = peer.send_reliable("schema", put_stamped_bytes("bad", "note:bad", vec![1, 2, 3], &text)?)?; + let reason = poll_until_rejected_or_stored(&mut server, &mut peer, &sink, 1)?; + if is_blank(&text) { + assert!(matches!(reason, Some(CultMeshRudpRejectionReason::MessageUnreadable(_))), "{text:?}: {reason:?}"); + } else { + assert_eq!(reason, Some(CultMeshRudpRejectionReason::StoredAtNotStamp), "{text:?}"); + let (error, code, details) = typed_refusal_seen_by(&mut peer, &receipt, Some(&mut server))?; + assert_eq!(code, Some(CultNetErrorCode::MessageInvalid), "{text:?}: {error}"); + assert_eq!(details.and_then(|d| d.field), Some("document".into()), "{text:?}"); + } + assert!(sink.0.lock().unwrap().receipts.is_empty(), "{text:?} reached the sink"); + } + // The server and its sink are still writable. + connection_id += 1; + let mut peer = client(server.local_addr()?, connection_id)?; + connect(&mut server, &mut [&mut peer])?; + stored_after_poll(&mut server, &mut peer, &sink, 1)?; + Ok(()) +} + +/// The size gate measures the document as a snapshot would serve it: a put over the bound that is also no stamp is +/// refused for its stamp first, and nothing is measured for one that is. +#[test] +fn the_stamp_is_judged_before_the_size_limit() -> Result<()> { + let over = document("big", vec![7; 101]); + let options = CultMeshRudpDocumentServerOptions { + max_snapshot_response_bytes: served_alone_bytes(&over)? - 1, + ..Default::default() + }; + let sink = Sink::default(); + let mut server = server(options, Clock::new(70_000), sink.clone(), Source::default())?; + let mut peer = client(server.local_addr()?, 900)?; + connect(&mut server, &mut [&mut peer])?; + peer.send_reliable("schema", put_stamped_bytes("o1", "big", vec![7; 101], "yesterday")?)?; + assert_eq!( + poll_until_rejected_or_stored(&mut server, &mut peer, &sink, 1)?, + Some(CultMeshRudpRejectionReason::StoredAtNotStamp) + ); + let mut stamped = client(server.local_addr()?, 901)?; + connect(&mut server, &mut [&mut stamped])?; + stamped.send_reliable("schema", put_stamped_bytes("o2", "big", vec![7; 101], "2026-06-25T00:00:00Z")?)?; + assert!(matches!( + poll_until_rejected_or_stored(&mut server, &mut stamped, &sink, 1)?, + Some(CultMeshRudpRejectionReason::DocumentUnservable { .. }) + )); + assert!(sink.0.lock().unwrap().receipts.is_empty()); + Ok(()) +} + +/// An ungranted peer is refused `write_not_authorized` before its stamp is judged, on a receipt session and on a +/// legacy one, and the authority is asked about the write. +#[test] +fn an_ungranted_peer_is_refused_for_authority_before_its_stamp_is_judged() -> Result<()> { + let sink = Sink::denying(&["secret"]); + let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; + let mut receipt_peer = client(server.local_addr()?, 950)?; + connect(&mut server, &mut [&mut receipt_peer])?; + send_reliable(&mut receipt_peer, &receipt_hello())?; + receipt_peer.send_reliable("schema", put_stamped_bytes("a1", "secret", vec![1], "yesterday")?)?; + let answers = answers_after(&mut server, &mut receipt_peer, 1)?; + assert_eq!(refusal_of(&answers[0]), (Some(CultNetErrorCode::WriteNotAuthorized), Some("a1".into()))); + assert_eq!(sink.0.lock().unwrap().authorized, vec!["a1".to_string()]); + + let mut legacy_peer = client(server.local_addr()?, 951)?; + connect(&mut server, &mut [&mut legacy_peer])?; + legacy_peer.send_reliable("schema", put_stamped_bytes("a2", "secret", vec![1], "yesterday")?)?; + assert!(matches!( + poll_until_rejected_or_stored(&mut server, &mut legacy_peer, &sink, 1)?, + Some(CultMeshRudpRejectionReason::SinkRefused(_)) + )); + assert!(sink.0.lock().unwrap().receipts.is_empty()); + Ok(()) +} diff --git a/packages/cultmesh-ts/CHANGELOG.md b/packages/cultmesh-ts/CHANGELOG.md index e9384f84..574a06a6 100644 --- a/packages/cultmesh-ts/CHANGELOG.md +++ b/packages/cultmesh-ts/CHANGELOG.md @@ -11,6 +11,7 @@ All notable changes to `@gamecult/cultmesh-ts` are recorded here, following ### Breaking +- `createRudpDocumentServer` refuses a peer put whose `storedAt` is missing, empty or not a stamp (`isStoredAtStamp`, the shape pinned in `tests/vectors/stored-at-stamp/`) with `message_invalid` naming `document`, after the write authority has been asked and before the put is sized; `onDocumentPutRaw` is not called, so nothing is stored and no stamp is minted (variants:ruling:raw-put-without-stamp-refused). `CultMeshRudpDocumentPut.storedAt` is the stamp the peer sent, never a server clock; a document synced from a peer with a stampless record is refused by the registry. - `publishRudpDocumentOnce` no longer treats a window with no refusal as published: a put nothing settled in its result window (500 ms, or 5000 ms against a receipt-capable server) rejects `CultMeshRudpWriteUnconfirmedError`, and a legacy server's echo of the put's id is not admission. It now resolves `"admitted"` (only on a `write_admitted`) or `"sent"`. **Migration:** a caller whose server cannot answer a write yet (for example Odin, until it is receipt-capable) passes `sendOnly: true` per call and treats `"sent"` as "sent", never as admitted. Receipts stay opt-in (`writeReceipts: true`); neither mode is the default. - The package is renamed `cultmesh-ts` to `@gamecult/cultmesh-ts` (the repo already scopes `@gamecult/cultcache-ts`), so the unscoped name cannot be claimed by someone else. Import from `@gamecult/cultmesh-ts` and its subpaths; the API is unchanged. Depends on `@gamecult/cultnet-ts`. - `asSchemaAlias` is removed from `CultMeshDocumentHandle`, `CultMeshCollectionHandle` and their bound forms. Read the handle by the schema you mean. diff --git a/packages/cultmesh-ts/package.json b/packages/cultmesh-ts/package.json index 7f2f20b6..af8e3584 100644 --- a/packages/cultmesh-ts/package.json +++ b/packages/cultmesh-ts/package.json @@ -49,7 +49,7 @@ "node": ">=20" }, "dependencies": { - "@gamecult/cultcache-ts": "^0.15.0", + "@gamecult/cultcache-ts": "^0.16.0", "@gamecult/cultnet-ts": "^0.4.0", "koffi": "3.3.0" }, diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index e6f71e21..50583888 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -5,6 +5,7 @@ import { CultCache, SchemaConflictError, SingleFileMessagePackBackingStore, + isStoredAtStamp, schemaIdentityOf, type AnyCultCacheDocumentDefinition, type CacheBackingStore, @@ -5813,6 +5814,12 @@ export class CultMesh { )); return; } + // A peer's put is stored under the stamp it carries: one that is no stamp is refused, after the authority + // has spoken and before the put is sized, so the size-limited path never measures it. Nothing is minted. + if (!isStoredAtStamp(message.document.storedAt)) { + sendSchemaMessage(record, cultNetMessageInvalidError("document", "Raw put documents must carry a storedAt stamp.")); + return; + } } if (options.onDocumentPutRaw) { const unservable = unservablePut(message); @@ -6635,7 +6642,7 @@ function normalizeRudpDocumentPut( return { schemaId: document.schemaId, recordKey: document.recordKey, - storedAt: document.storedAt ?? new Date().toISOString(), + storedAt: document.storedAt, sourceRuntimeId: document.sourceRuntimeId ?? null, sourceAgentId: document.sourceAgentId ?? null, sourceRole: document.sourceRole ?? null, diff --git a/packages/cultmesh-ts/test/cultmesh.test.ts b/packages/cultmesh-ts/test/cultmesh.test.ts index 341a35b3..41230aa9 100644 --- a/packages/cultmesh-ts/test/cultmesh.test.ts +++ b/packages/cultmesh-ts/test/cultmesh.test.ts @@ -3423,11 +3423,12 @@ const servedBoundRegistry = () => /** * A put's own storedAt, kept by the served record and replaced in the receipt by the server's - * 24-character clock. `SERVED_STORED_AT` is long, so an echoed receipt is smaller than the served - * reply; `RECEIPT_STORED_AT` is short, so an echoed receipt is the larger reply, which shows that + * 24-character clock. `SERVED_STORED_AT` is as long as a stamp can be (a stamp is judged before a put is + * sized, so padding one is refused), so an echoed receipt is smaller than the served reply; + * `RECEIPT_STORED_AT` is short, so an echoed receipt is the larger reply, which shows that * admission sizes only the served reply. */ -const SERVED_STORED_AT = `2026-09-30T00:00:00.000Z${"~".repeat(200)}`; +const SERVED_STORED_AT = "2026-09-30T00:00:00.123456789+00:00"; // the longest a stamp can be, 35 characters const RECEIPT_STORED_AT = "2026-09-30T00:00:00.000Z"; const SERVED_BOUND_SERVER = "cultmesh-ts-served-bound-server"; diff --git a/packages/cultmesh-ts/test/remote-write-authority.test.ts b/packages/cultmesh-ts/test/remote-write-authority.test.ts index 3c35171e..d019e71b 100644 --- a/packages/cultmesh-ts/test/remote-write-authority.test.ts +++ b/packages/cultmesh-ts/test/remote-write-authority.test.ts @@ -7,6 +7,7 @@ import { join } from "node:path"; import test from "node:test"; import { z } from "zod"; import { documentPayloadFromBytes, defineDocumentType } from "@gamecult/cultcache-ts"; +import { decode } from "@msgpack/msgpack"; import { decodeRudpPacket } from "@gamecult/cultnet-ts"; import { encodeWireValue } from "@gamecult/cultnet-ts/wire"; import { @@ -310,6 +311,64 @@ test("the refusal on the wire is the pinned error-vectors bytes", async () => { }); }); +// A peer's put is stored under the stamp it carries: every row of the shared vector goes through the real handler. A row that +// is no stamp is refused message_invalid naming `document`, on the plain, the size-limited and (RUDP is this server's only +// transport) the capped path; the handler never runs, so nothing is stored or minted, and a stamped put after it still lands. +const STAMP_VECTOR = join(__dirname, "..", "..", "..", "..", "tests", "vectors", "stored-at-stamp", "stamps.msgpack"); + +test("a put whose storedAt is no stamp is refused message_invalid (document), for every row of the stamp vector", async () => { + const rows = decode(readFileSync(STAMP_VECTOR)) as [string, boolean][]; + const cases: [string, unknown, boolean][] = [...rows.map(([text, parses]): [string, unknown, boolean] => [JSON.stringify(text), text, parses]), + ["missing", undefined, false], ["number", 1_700_000_000_000, false]]; + for (const extra of [{}, { maxPayloadBytes: 4096 }]) { + await using(await rig(new CultMeshGrantAllWrites(), extra), async (r) => { + let accepted = 0; + for (const [label, storedAt, parses] of cases) { + const message = put(KEY_A) as unknown as { document: Record }; + if (storedAt === undefined) delete message.document.storedAt; else message.document.storedAt = storedAt; + const seen = r.replies.length; + const handled = r.puts.length; + r.peer.send(message as unknown as CultNetMessage); + await sleep(40); + if (parses) { + accepted += 1; + assert.equal(r.puts.length, handled + 1, `${label}: a stamp reaches the handler`); + assert.equal(r.replies.length, seen, label); + } else { + assert.equal(r.puts.length, handled, `${label}: nothing handled, so nothing minted or stored`); + const reply = r.replies[seen] as { schemaVersion?: string; code?: string; details?: { field?: string } } | undefined; + assert.equal(reply?.schemaVersion, "cultnet.error.v0", label); + assert.equal(reply?.code, "message_invalid", label); + assert.equal(reply?.details?.field, "document", label); + } + } + assert.ok(accepted > 0); + assert.deepEqual(r.errors, []); + // The large put under a garbage stamp is refused for its stamp, before it is measured. + const large = put(KEY_A, {}, SCHEMA_ID, new Uint8Array(20_000)) as unknown as { document: Record }; + large.document.storedAt = "yesterday"; + r.peer.send(large as unknown as CultNetMessage); + await sleep(60); + const last = r.replies[r.replies.length - 1] as { code?: string; details?: { field?: string } }; + assert.equal(last.code, "message_invalid"); + assert.equal(last.details?.field, "document"); + }); + } + // The stamp is judged after the authority: an ungranted peer hears write_not_authorized, aliased or not. + const refusing = new Recording(() => ({ granted: false, reason: "no" })); + await using(await rig(refusing), async (r) => { + const aliased = put(KEY_A, {}, ALIAS) as unknown as { document: Record }; + aliased.document.storedAt = "yesterday"; + r.peer.send(aliased as unknown as CultNetMessage); + r.peer.send(put(KEY_B)); + await r.settle(); + assertRefused(r.replies[0], KEY_A); + assertRefused(r.replies[1], KEY_B); + assert.equal(refusing.requests[0]?.schemaId, SCHEMA_ID, "an aliased put asks under the canonical id"); + assert.equal(r.puts.length, 0); + }); +}); + test("wrong-typed shard fields on a put are judged with null shard fields, then refused as malformed once granted", async () => { const authority = new Recording(grant); await using(await rig(authority), async (r) => { diff --git a/packages/cultnet-py/CHANGELOG.md b/packages/cultnet-py/CHANGELOG.md index b7e73ae3..0652ad0a 100644 --- a/packages/cultnet-py/CHANGELOG.md +++ b/packages/cultnet-py/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased (breaking; version 0.x, so the next minor) +- `raw_record_to_envelope`, where a replicated record becomes an envelope, refuses a record whose `storedAt` is missing, empty or not a stamp (`cultcache_py.stores.parse_stored_at`, the shape pinned by `tests/vectors/stored-at-stamp`) with `ValueError`, nothing minted or stored (`variants:ruling:raw-put-without-stamp-refused`). `apply_raw_document_record`, `apply_raw_snapshot` and `apply_shard_log_response` come through it: a snapshot response with such a record is refused whole before any of its records is stored, as one naming an unregistered schema is; a shard log refuses at that entry, the entries before it already applied. It stored the record under `""`, and the next open of the store raised `CultCacheError` `Missing envelope`. `cultmesh-py`'s raw put builds its envelope through the same function, so there is one owner of the check there; its server gate still answers a peer's put `message_invalid`. A client's `read_next_change` lets the `ValueError` out. - `decode_witness_artifact_bundle_payload` (and `CultNetWitnessArtifactBundle.from_payload`) read the payload through `cultcache_py.read_payload_tree`: bytes after its first MessagePack value raise `ValueError` `cultnet.witness_artifact_bundle payload has N bytes after its first MessagePack value (refused on read).` instead of `msgpack.ExtraData`, and a payload nested past 256 raises `CultCachePayloadDepthError`. Needs `cultcache-py` 0.4.0, the release that exports `read_payload_tree`: the dependency is now `cultcache-py>=0.4.0` (it was `>=0.2.0`, which admits releases whose `import cultnet_py` fails), and `tests/test_declared_dependency_ranges.py` fails when a declared bound admits a tagged release lacking a name the package imports. The same check found `cultmesh-py` importing `cache.index_text`, which `cultcache-py` 0.2.0 lacks; its bound is `cultcache-py>=0.4.0` too (it has no changelog of its own). `resolve_document_for_raw_record` reads the schema hint from a raw record's first value whether or not bytes follow it, so a record routed by its stamp is refused by count when applied (`ValueError`, in a snapshot, a shard log and a direct apply) like one routed by a registered schema id; `apply_shard_log_response` used to skip it silently as an unknown schema. A tail on a payload whose first value names no registered schema is still `KeyError`, which a shard log skips. - `apply_raw_document_record` and `apply_raw_snapshot` raise `SchemaConflictError` when a remote record is of another type than the one the cache holds at its key (`cultcache-py` holds one record per key whatever its type); the cache is unchanged, and the caller decides whether to drop the record or the peer. `cultmesh-py`'s server answers such a put with `message_invalid`; its client's `read_next_change` lets the error out. - `CultNetDatabaseChange` carries `commit_id` and `commit_count` (`commitId`/`commitCount`; None outside a multi-record commit, as C# writes nil): `from_wire` refuses an empty or non-string id and a count that is not an integer of at least 2, `to_wire` writes them when set, and the published `database_change_raw` schema lists both. Python applies no batch. The `simulation_fact` document's `shard`, `frame`, `subject` and `claim_kind` aliases are `roles` (many facts share each); the catalog bytes do not move. diff --git a/packages/cultnet-py/src/cultnet_py/replication.py b/packages/cultnet-py/src/cultnet_py/replication.py index 345f1c62..41d63295 100644 --- a/packages/cultnet-py/src/cultnet_py/replication.py +++ b/packages/cultnet-py/src/cultnet_py/replication.py @@ -5,6 +5,7 @@ from typing import Any, Iterable from cultcache_py import CultCache, CultCacheEnvelope from cultcache_py.documents import DocumentDefinition +from cultcache_py.stores import parse_stored_at from .shard_log import CultNetShardLogResponse from .snapshot import CultNetRawDocumentRecord, CultNetRawSnapshotResponse @@ -50,17 +51,29 @@ def apply_raw_document_record( ) +def require_stored_at_stamp(record: dict[str, Any]) -> str: + """The storedAt a replicated raw record carries; ValueError when it is missing or no stamp. The one place the rule is judged.""" + stored_at = record.get("storedAt") + if parse_stored_at(stored_at) is None: + raise ValueError("A replicated raw record must carry a storedAt stamp.") + return stored_at + + def raw_record_to_envelope( document: DocumentDefinition[Any], schema_id: str, record: dict[str, Any], ) -> CultCacheEnvelope: + """The envelope a replicated raw record is stored as; the one place a record becomes an envelope (a direct apply, a snapshot and a + shard log all come through here). A peer's record is stored under the stamp it carries, so one whose storedAt is missing, empty or + no stamp is refused with ValueError and nothing is minted or stored (variants:ruling:raw-put-without-stamp-refused).""" + stored_at = require_stored_at_stamp(record) return CultCacheEnvelope( key=str(record["recordKey"]), type=document.type, schema_id=schema_id, payload=bytes(record["payload"]), - stored_at=str(record.get("storedAt") or ""), + stored_at=stored_at, catalog_entry=document.catalog_entry(), ) @@ -117,6 +130,12 @@ def apply_shard_log_response( reason = response.get("reason") or "unspecified" raise ValueError(f"Shard log response requires resync: {reason}") documents_by_schema_id = schema_document_map(documents) + # The whole log is refused before anything is stored: every put that would be applied is judged for its stamp first. + for entry in response.get("entries", []): + if isinstance(entry, dict) and entry.get("changeKind") in ("added", "updated") and isinstance(entry.get("put"), dict): + record = entry["put"]["document"] + if can_resolve_document_for_raw_record(documents_by_schema_id, str(record["schemaId"]), record): + require_stored_at_stamp(record) applied: list[CultNetAppliedRecord] = [] for entry in response.get("entries", []): if not isinstance(entry, dict): diff --git a/packages/cultnet-py/tests/test_raw_record_stamp.py b/packages/cultnet-py/tests/test_raw_record_stamp.py new file mode 100644 index 00000000..d6e6cba6 --- /dev/null +++ b/packages/cultnet-py/tests/test_raw_record_stamp.py @@ -0,0 +1,57 @@ +"""raw_record_to_envelope is the one place a replicated raw record becomes an envelope; it refuses a record whose storedAt is no stamp. + +The stamp shape is tests/vectors/stored-at-stamp/stamps.msgpack (variants:ruling:raw-put-without-stamp-refused); cultmesh-py tests the +whole replication paths, this tests the function itself. +""" + +from __future__ import annotations + +import unittest +from pathlib import Path +from typing import Any + +import msgpack +from cultcache_py import define_database_entry_type +from cultnet_py.replication import raw_record_to_envelope + +VECTOR = Path(__file__).resolve().parents[3] / "tests/vectors/stored-at-stamp/stamps.msgpack" +ROWS: list[tuple[str, bool]] = [(text, parses) for text, parses in msgpack.unpackb(VECTOR.read_bytes(), raw=False)] +NOTE = define_database_entry_type( + "tests.envelope_note", + [("schema_version", 0), ("text", 1)], + schema_id="sha256:envelope-note", + schema_name="tests.envelope_note", + schema_version="tests.envelope_note.v1", +) + + +def record_with(stamp: Any) -> dict[str, Any]: + record = { + "schemaId": "sha256:envelope-note", + "recordKey": "note:1", + "payload": NOTE.encode_payload({"schema_version": "tests.envelope_note.v1", "text": "t"}), + } + if stamp is not None: + record["storedAt"] = stamp + return record + + +class RawRecordToEnvelopeStampTests(unittest.TestCase): + def test_every_vector_row_is_an_envelope_or_a_refusal_as_the_vector_says(self) -> None: + self.assertGreater(len(ROWS), 30) + for text, parses in ROWS: + with self.subTest(text=text): + if parses: + self.assertEqual(raw_record_to_envelope(NOTE, "sha256:envelope-note", record_with(text)).stored_at, text) + else: + with self.assertRaises(ValueError): + raw_record_to_envelope(NOTE, "sha256:envelope-note", record_with(text)) + + def test_a_missing_or_non_string_stamp_is_refused(self) -> None: + for stamp in (None, 12345, b"2026-11-07T00:00:00Z"): + with self.subTest(stamp=stamp), self.assertRaises(ValueError): + raw_record_to_envelope(NOTE, "sha256:envelope-note", record_with(stamp)) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/cultnet-ts/CHANGELOG.md b/packages/cultnet-ts/CHANGELOG.md index 44d3d5ec..b2580aba 100644 --- a/packages/cultnet-ts/CHANGELOG.md +++ b/packages/cultnet-ts/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to `@gamecult/cultnet-ts` are recorded here, following ### Breaking +- `CultNetDocumentRegistry.applyRawDocumentPutMessage` and `createRawSnapshotResponseForPut` throw `A raw put must carry a storedAt stamp.` when the put's `storedAt` is missing, empty or not a stamp (`isStoredAtStamp` of cultcache-ts), and nothing is stored. The parser no longer refuses a `cultnet.document_put_raw.v0` for its `storedAt`: the server and the node judge it, so a refusal can name `document` (snapshot records and database changes are still read strictly as non-empty). - `decodeManifest` refuses a manifest with bytes after its first MessagePack value (`CDN artifact manifest has N bytes after its first MessagePack value.`), as the C# reference does; before, the tail was ignored. `contracts/cultmesh/content-acceptance-vectors.json` now holds `trailing_nil`, `trailing_reserved_code` and `trailing_truncated_array` as refused. ### Added diff --git a/packages/cultnet-ts/package.json b/packages/cultnet-ts/package.json index 27ba3be4..1ebba407 100644 --- a/packages/cultnet-ts/package.json +++ b/packages/cultnet-ts/package.json @@ -73,7 +73,7 @@ "dependencies": { "@msgpack/msgpack": "^3.1.3", "ajv": "^8.17.1", - "@gamecult/cultcache-ts": "^0.15.0" + "@gamecult/cultcache-ts": "^0.16.0" }, "devDependencies": { "@types/node": "^24.0.0", diff --git a/packages/cultnet-ts/src/contracts.ts b/packages/cultnet-ts/src/contracts.ts index b733c959..5b06d5bc 100644 --- a/packages/cultnet-ts/src/contracts.ts +++ b/packages/cultnet-ts/src/contracts.ts @@ -1289,7 +1289,9 @@ function validateDocumentPutRawMessage(input: unknown): asserts input is CultNet throw new Error("Validation failed for cultnet.document_put_raw.v0: /document: is required"); } - validateRawDocumentRecord(candidate.document, "/document"); + // A peer's put is judged for its stamp by the server and the node (isStoredAtStamp), which answer message_invalid naming + // `document` after the write authority has spoken; the parser must let such a put through to them. + validateRawDocumentRecord(candidate.document, "/document", { judgesStoredAt: false }); } function validateSnapshotResponseRawMessage( @@ -1450,6 +1452,7 @@ function validateBinaryMessageRoot( function validateRawDocumentRecord( input: unknown, instancePath: string, + { judgesStoredAt = true }: { judgesStoredAt?: boolean } = {}, ): asserts input is CultNetRawDocumentRecord { if (!input || typeof input !== "object") { throw new Error(`Validation failed for raw CultNet document record: ${instancePath}: must be an object`); @@ -1461,7 +1464,9 @@ function validateRawDocumentRecord( requireOptionalNonEmptyString(candidate.schemaVersion, `${instancePath}/schemaVersion`); requireOptionalNonEmptyString(candidate.schemaContentHash, `${instancePath}/schemaContentHash`); requireNonEmptyString(candidate.recordKey, `${instancePath}/recordKey`); - requireNonEmptyString(candidate.storedAt, `${instancePath}/storedAt`); + if (judgesStoredAt) { + requireNonEmptyString(candidate.storedAt, `${instancePath}/storedAt`); + } if (candidate.payloadEncoding !== "messagepack") { throw new Error( `Validation failed for raw CultNet document record: ${instancePath}/payloadEncoding: must equal messagepack`, diff --git a/packages/cultnet-ts/src/replication.ts b/packages/cultnet-ts/src/replication.ts index 32ee4163..96b07cc3 100644 --- a/packages/cultnet-ts/src/replication.ts +++ b/packages/cultnet-ts/src/replication.ts @@ -6,6 +6,7 @@ import type { } from "@gamecult/cultcache-ts"; import { SchemaConflictError, + isStoredAtStamp, decodeDocumentPayload, documentPayloadBytes, documentPayloadFromBytes, @@ -323,6 +324,10 @@ export class CultNetDocumentRegistry { envelope: CultCacheEnvelope; } { const binding = this.#requireSchemaBinding(message.document.schemaId); + // A put is stored under the stamp it carries; one that is no stamp is refused, and nothing is minted for it. + if (!isStoredAtStamp(message.document.storedAt)) { + throw new Error("A raw put must carry a storedAt stamp."); + } return { binding, envelope: { diff --git a/packages/cultnet-ts/test/raw-put-stamp.test.ts b/packages/cultnet-ts/test/raw-put-stamp.test.ts new file mode 100644 index 00000000..e358c080 --- /dev/null +++ b/packages/cultnet-ts/test/raw-put-stamp.test.ts @@ -0,0 +1,53 @@ +// A peer's raw put is stored under the stamp it carries; one that is no stamp is refused and nothing is minted or stored +// (variants:ruling:raw-put-without-stamp-refused). The registry is the node-level owner: every row of the shared vector. +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import test from "node:test"; +import { decode } from "@msgpack/msgpack"; +import { z } from "zod"; +import { CultCache, defineDocumentType, encodeDocumentPayload } from "@gamecult/cultcache-ts"; +import { CultNetDocumentRegistry, defineCultNetDocumentBinding, parseCultNetMessage, type CultNetMessage } from "../src"; + +const VECTOR = join(__dirname, "..", "..", "..", "..", "tests", "vectors", "stored-at-stamp", "stamps.msgpack"); +const note = defineDocumentType({ + type: "tests.stamped_note", + schemaId: "tests.stamped_note.v0", + schema: z.object({ noteId: z.string(), text: z.string() }), + name: "noteId", +}); +const registry = new CultNetDocumentRegistry([defineCultNetDocumentBinding({ definition: note })]); + +function putOf(key: string, storedAt: unknown): CultNetMessage { + const payload = encodeDocumentPayload(note, { noteId: key, text: "peer" }); + const document: Record = { schemaId: note.schemaId, recordKey: key, payloadEncoding: "messagepack", payload }; + if (storedAt !== undefined) document.storedAt = storedAt; + return { schemaVersion: "cultnet.document_put_raw.v0", messageId: `put-${key}`, document } as unknown as CultNetMessage; +} + +test("the registry stores a raw put only under a stamp, for every row of the vector and for a missing or mistyped one", async () => { + const rows = decode(readFileSync(VECTOR)) as [string, boolean][]; + const cases: [string, unknown, boolean][] = [...rows.map(([text, parses]): [string, unknown, boolean] => [JSON.stringify(text), text, parses]), + ["missing", undefined, false], ["number", 1_700_000_000_000, false], ["null", null, false]]; + const cache = CultCache.builder().withDocumentType(note).build(); + for (const [label, storedAt, parses] of cases) { + const message = putOf("k", storedAt); + // The parser lets a put through to its judges: the server and the registry refuse it, naming the document. + parseCultNetMessage(message, "cultnet.schema.v0"); + const before = cache.getEnvelope(note, "k"); + if (parses) { + await registry.applyRawDocumentPutMessage(cache, message as never); + assert.equal(cache.getRequiredEnvelope(note, "k").storedAt.length > 0, true, label); + } else { + await assert.rejects(registry.applyRawDocumentPutMessage(cache, message as never), /storedAt stamp/, label); + assert.deepEqual(cache.getEnvelope(note, "k"), before, `${label}: nothing minted or stored`); + } + } + // The refused puts left the store writable. + await registry.applyRawDocumentPutMessage(cache, putOf("after", "2026-11-07T00:00:00Z") as never); + assert.equal(cache.getRequired(note, "after").text, "peer"); +}); + +test("a refused put does not serve either: createRawSnapshotResponseForPut refuses it too", () => { + assert.throws(() => registry.createRawSnapshotResponseForPut(putOf("k", "yesterday") as never, "m"), /storedAt stamp/); +}); diff --git a/src/GameCult.Networking/CultNetDatabase.cs b/src/GameCult.Networking/CultNetDatabase.cs index 9db3cd1b..abd04d01 100644 --- a/src/GameCult.Networking/CultNetDatabase.cs +++ b/src/GameCult.Networking/CultNetDatabase.cs @@ -1055,6 +1055,13 @@ namespace GameCult.Networking var key = new CultRecordKey(message.Document.RecordKey); var descriptor = _documents.ResolveDescriptorForRawDocument(message.Document); authorize?.Invoke(descriptor.SchemaId, key); + // A peer's put is judged for its stamp after its authority and before any shard is asked (so it is never forwarded): + // the cache would mint a stamp for one that is no stamp, and a refused put stores and mints nothing. + if (authorize != null && !CultNetStoredAtStamp.IsStamp(message.Document.StoredAt)) + { + throw new CultNetStoredAtStampException(); + } + var shard = ResolveShardInternal(descriptor, key); EnsurePrimary(shard, descriptor.SchemaId, key, message.ShardEpoch); var document = _documents.DeserializeRawDocument(message.Document); diff --git a/src/GameCult.Networking/CultNetDatabaseServer.cs b/src/GameCult.Networking/CultNetDatabaseServer.cs index 32f302fc..573c8f0f 100644 --- a/src/GameCult.Networking/CultNetDatabaseServer.cs +++ b/src/GameCult.Networking/CultNetDatabaseServer.cs @@ -475,6 +475,11 @@ namespace GameCult.Networking _server.Logger.LogWarning($"CultNet raw put refused: {ex.Message}"); Refuse(peer, write, CultNetErrorMessage.ForRemoteWriteRefused(ex.Request, ex.Reason)); } + catch (CultNetStoredAtStampException ex) + { + _server.Logger.LogWarning($"CultNet raw put refused: {ex.Message}"); + Refuse(peer, write, CultNetErrorMessage.ForMessageInvalid("document", "document.storedAt is not a stamp")); + } catch (CultNetShardAuthorityException ex) { bool forwarded; diff --git a/src/GameCult.Networking/CultNetStoredAtStamp.cs b/src/GameCult.Networking/CultNetStoredAtStamp.cs new file mode 100644 index 00000000..a4ead645 --- /dev/null +++ b/src/GameCult.Networking/CultNetStoredAtStamp.cs @@ -0,0 +1,63 @@ +#nullable enable +using System; + +namespace GameCult.Networking +{ + // The one shape a peer's storedAt must have: RFC 3339 date-time, YYYY-MM-DDThh:mm:ss, an optional fraction of 1 to 9 digits, + // then Z or +hh:mm / -hh:mm. Uppercase T and Z, ASCII digits, no whitespace, a real calendar date, seconds 00-59, years + // 0001-9999 for both the local date and the UTC instant, offset hours 00-23 and minutes 00-59. Checked by shape and range, never by DateTimeOffset.TryParse, which is + // culture-aware and takes far more than this (tests/vectors/stored-at-stamp, variants:ruling:raw-put-without-stamp-refused). + internal static class CultNetStoredAtStamp + { + public static bool IsStamp(string? text) + { + if (text == null || text.Length < 20) return false; + if (!Digits(text, 0, 4, out var year) || text[4] != '-' || !Digits(text, 5, 2, out var month) || text[7] != '-' + || !Digits(text, 8, 2, out var day) || text[10] != 'T' || !Digits(text, 11, 2, out var hour) || text[13] != ':' + || !Digits(text, 14, 2, out var minute) || text[16] != ':' || !Digits(text, 17, 2, out var second)) + return false; + if (year < 1 || month is < 1 or > 12 || day < 1 || day > DateTime.DaysInMonth(year, month) + || hour > 23 || minute > 59 || second > 59) + return false; + + var i = 19; + if (text[i] == '.') + { + var start = ++i; + while (i < text.Length && IsDigit(text[i])) i++; + if (i - start is < 1 or > 9) return false; + } + + if (i >= text.Length) return false; + if (text[i] == 'Z') return i == text.Length - 1; + if (!(text.Length - i == 6 && (text[i] == '+' || text[i] == '-') + && Digits(text, i + 1, 2, out var offsetHour) && text[i + 3] == ':' && Digits(text, i + 4, 2, out var offsetMinute) + && offsetHour <= 23 && offsetMinute <= 59)) + return false; + // The UTC instant must lie in years 0001-9999 too: local minus offset may not leave DateTime's range. + var offsetTicks = (offsetHour * 60L + offsetMinute) * TimeSpan.TicksPerMinute * (text[i] == '-' ? -1 : 1); + var utcTicks = new DateTime(year, month, day, hour, minute, second).Ticks - offsetTicks; + return utcTicks >= DateTime.MinValue.Ticks && utcTicks <= DateTime.MaxValue.Ticks; + } + + private static bool IsDigit(char c) => c is >= '0' and <= '9'; + + private static bool Digits(string text, int start, int count, out int value) + { + value = 0; + for (var i = start; i < start + count; i++) + { + if (i >= text.Length || !IsDigit(text[i])) return false; + value = value * 10 + (text[i] - '0'); + } + + return true; + } + } + + // A peer's raw put whose storedAt is no stamp: refused message_invalid, field document, nothing stored or minted. + internal sealed class CultNetStoredAtStampException : Exception + { + public CultNetStoredAtStampException() : base("a raw put document must carry a storedAt stamp") { } + } +} diff --git a/tests/GameCult.Networking.Tests/CultNetWriteReceiptServerTests.cs b/tests/GameCult.Networking.Tests/CultNetWriteReceiptServerTests.cs index e42961a2..bbaf2f68 100644 --- a/tests/GameCult.Networking.Tests/CultNetWriteReceiptServerTests.cs +++ b/tests/GameCult.Networking.Tests/CultNetWriteReceiptServerTests.cs @@ -1119,6 +1119,8 @@ namespace GameCult.Networking.Tests foreach (var field in BlankRuleFields) foreach (var times in new[] { 1, 3 }) { + // A storedAt must be a stamp: a non-blank one that is no stamp is not "a name" (write-receipt.md, Required fields). + if (!blank && field == "storedAt") continue; var text = string.Concat(Enumerable.Repeat(char.ConvertFromUtf32(Convert.ToInt32(hex, 16)), times)); var rig = Build(); var wire = new Wire(); diff --git a/tests/GameCult.Networking.Tests/RemoteWriteAuthorityTests.cs b/tests/GameCult.Networking.Tests/RemoteWriteAuthorityTests.cs index 897674b1..7cdaecbc 100644 --- a/tests/GameCult.Networking.Tests/RemoteWriteAuthorityTests.cs +++ b/tests/GameCult.Networking.Tests/RemoteWriteAuthorityTests.cs @@ -405,6 +405,114 @@ namespace GameCult.Networking.Tests Assert.That(rig.Logger.Errors, Is.Empty, "a null decision is a refusal, not a fault"); } + // ---- a peer's storedAt must be a stamp (tests/vectors/stored-at-stamp, variants:ruling:raw-put-without-stamp-refused) ---- + + private static (string Text, bool Parses)[] StampVectors() + { + var dir = new System.IO.DirectoryInfo(TestContext.CurrentContext.TestDirectory); + while (dir != null && !System.IO.Directory.Exists(System.IO.Path.Combine(dir.FullName, "tests", "vectors"))) dir = dir.Parent; + var bytes = System.IO.File.ReadAllBytes(System.IO.Path.Combine(dir!.FullName, "tests", "vectors", "stored-at-stamp", "stamps.msgpack")); + return MessagePack.MessagePackSerializer.Deserialize(bytes) + .Select(row => (Row: (object[])row, 0)).Select(r => ((string)r.Row[0], (bool)r.Row[1])).ToArray(); + } + + // A blank stamp is a frame that does not read (write-receipt.md, vector blankFields: field "message"); any other text + // that is no stamp is message_invalid on field "document". Both are refused before the store is asked. + private static bool IsBlankText(string text) => text.All(c => char.IsWhiteSpace(c)); + + [Test] + public async Task EveryStampVectorRowIsJudgedThroughTheRealServerHandler() + { + using var wire = new WirePair(); + var rig = Build(new RecordingAuthority(_ => CultNetRemoteWriteDecision.Grant()), peer: wire.Peer); + var rows = StampVectors(); + Assert.That(rows, Has.Length.GreaterThan(30)); + var n = 0; + foreach (var (text, parses) in rows) + { + var key = new CultRecordKey($"tests:stamp:{n++}"); + var put = rig.PutOf(key, "peer"); + put.Document.StoredAt = text; + var publishedBefore = rig.Published.Count; + var logBefore = rig.LogLength; + + await rig.Put(put); + + if (parses) + { + Assert.That(rig.TextOf(key), Is.EqualTo("peer"), $"stamp '{text}' is admitted"); + Assert.That(wire.Errors, Is.Empty, $"stamp '{text}' is not answered with an error"); + continue; + } + + var reply = await wire.NextErrorAsync(); + Assert.That(reply.Code, Is.EqualTo("message_invalid"), $"'{text}'"); + Assert.That(reply.Details!.Field, Is.EqualTo(IsBlankText(text) ? "message" : "document"), $"'{text}'"); + Assert.That(rig.TextOf(key), Is.Null, $"'{text}' stored nothing"); + Assert.That(rig.Cache.Get(key), Is.Null, $"'{text}' minted nothing"); + Assert.That(rig.Published, Has.Count.EqualTo(publishedBefore), $"'{text}' published nothing"); + Assert.That(rig.LogLength, Is.EqualTo(logBefore), $"'{text}' logged nothing"); + Assert.That(wire.Errors, Is.Empty, "one reply per refused write"); + } + + var fresh = new CultRecordKey("tests:stamp:after"); + await rig.Put(rig.PutOf(fresh, "after")); + Assert.That(rig.TextOf(fresh), Is.EqualTo("after"), "the store stays writable after the refusals"); + Assert.That(wire.Errors, Is.Empty); + } + + // The host's own door (the public ApplyPutAsync overload, no authorize) is exempt from the stamp judgment: the cache mints + // a stamp for it. Applying the check to every call would refuse this put. + [Test] + public async Task TheHostsOwnDoorIsExemptFromTheStampJudgment() + { + var rig = Build(new RecordingAuthority(_ => CultNetRemoteWriteDecision.Refuse("no"))); + var put = rig.PutOf(KeyA, "host"); + put.Document.StoredAt = "yesterday"; + + await rig.Database.ApplyPutAsync(put); + + Assert.That(rig.TextOf(KeyA), Is.EqualTo("host"), "the host's put with no stamp is applied, not refused"); + } + + [Test] + public async Task AnUngrantedPeerIsToldItIsNotAuthorizedBeforeItsStampIsJudgedAndAnAliasAsksTheCanonicalId() + { + using var wire = new WirePair(); + var authority = new RecordingAuthority(_ => CultNetRemoteWriteDecision.Refuse("no")); + var rig = Build(authority, peer: wire.Peer); + var put = rig.PutOf(KeyA, "peer"); + put.Document.StoredAt = "yesterday"; + put.Document.SchemaId = "runtime.generated.network-note.ui.42"; + + await rig.Put(put); + var reply = await wire.NextErrorAsync(); + + Assert.That(reply.Code, Is.EqualTo("write_not_authorized"), "authority first, the stamp is not judged"); + Assert.That(authority.Requests.Single().SchemaId, Is.EqualTo(rig.SchemaId)); + Assert.That(rig.TextOf(KeyA), Is.Null); + } + + [Test] + public async Task AGarbageStampIsNeverForwardedToThePrimary() + { + using var wire = new WirePair(); + var forwarder = new CountingForwarder(); + var rig = Build(new RecordingAuthority(_ => CultNetRemoteWriteDecision.Grant()), primary: false, forwarder, wire.Peer); + var put = rig.PutOf(KeyA, "peer"); + put.Document.StoredAt = "2026-06-25T00:00:00"; + + await rig.Put(put); + var reply = await wire.NextErrorAsync(); + + Assert.That(reply.Code, Is.EqualTo("message_invalid")); + Assert.That(reply.Details!.Field, Is.EqualTo("document")); + Assert.That(forwarder.Puts, Is.EqualTo(0)); + + await rig.Put(rig.PutOf(KeyA, "peer")); + Assert.That(forwarder.Puts, Is.EqualTo(1), "a stamped put still forwards"); + } + private sealed class RecordingAuthority : ICultNetRemoteWriteAuthority { private readonly Func _decide; diff --git a/tests/vectors/stored-at-stamp/README.md b/tests/vectors/stored-at-stamp/README.md new file mode 100644 index 00000000..76c0f216 --- /dev/null +++ b/tests/vectors/stored-at-stamp/README.md @@ -0,0 +1,11 @@ +# stored-at-stamp vectors + +`stamps.msgpack`: an array of `[text, parses]`. `text` (str) is a `storedAt` a peer might send; `parses` is whether it is a stamp. +Every runtime that judges a peer's `storedAt` (the raw put of cultmesh-py, and the C#, TypeScript and Rust servers) is held to +these answers, and a text that does not parse is refused `message_invalid` (field `document`) with nothing minted or stored +(`variants:ruling:raw-put-without-stamp-refused`). + +A stamp is an RFC 3339 date-time: `YYYY-MM-DDThh:mm:ss`, an optional fraction of 1 to 9 digits, and `Z` or a numeric offset +`+hh:mm` / `-hh:mm` (hours 00-23, minutes 00-59). Uppercase `T` and `Z`, ASCII digits, no surrounding whitespace, a real +calendar date, seconds 00-59 (no leap second). The local year must be 1-9999 AND the UTC instant (after the offset is applied) must lie in years 0001-9999. The reference is `parse_stored_at` in +`packages/cultcache-py/src/cultcache_py/stores.py` (#172); this file was written from it and is a reviewed commit when it changes. diff --git a/tests/vectors/stored-at-stamp/stamps.msgpack b/tests/vectors/stored-at-stamp/stamps.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..56427db2512d4f7bc48d830e87a9ff1237292bde GIT binary patch literal 1162 zcmaJ=OHRWu5M|v{ly&7Wc0O%#j;y*vq8ET}$}xO`mIV^5K(we82qfnekvI%*+{8`7 z6w8&;_hvkAem@sic5BFFiZW%=lBn=^UfFj6jx>l5kuY(RW?(1_!}l z;x);fYPfnVI7Y-rPbe*7>ie6xNON4}KPp>a<~T|l73;DkBgSxA0UQKIq;W&7jzOrk z;c_O5j52wtO(^Hu*{9XwZn?O%9;Yc@jCfPbNXWXE;2Yf$QjM1Z32gTl!b<5W^g7Q14)^<8B)QjMB#xk^{UG zvWS376OK`F_FHn$e!{1-oOSK%hkA|Q<8W`BeLgum`+E9wR0Q7_#whdK#_0iSisEOc zQ~s^aIr|}efD=z(H+w&Q8l{&wKhEHIlm`>;*N6QiuJvP;L%)WD^1l)*-?yPeUT#5M Gv%tTl$~ZLu literal 0 HcmV?d00001

    Dt0_0*l$%DmOj>H=`jeW6 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 ZQ^SGeG;aop`RwICH7WYc!AP_EgCxCSB7&Z|E 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 diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.pdb b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.pdb index 4fcf53c6351ee2bad8df864cc9d72354979dc69c..8cb5dad78ed44861fe2a0bfece08df8d74547f91 100644 GIT binary patch delta 132781 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 delta 117699 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 From a57397aa7fb2503acd0ec4c0dd613d3aed5aa88c Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 11:42:27 +0000 Subject: [PATCH 23/93] Unique-index vector: group identity, distinct values, normal-form keys, single-put alias order (rows 26-33) (#104) --- .../UniqueIndexVectorTests.cs | 22 +++++++++++++++++- tests/vectors/unique-index/README.md | 21 ++++++++++------- .../unique-index/cross-schema-store.cc | Bin 0 -> 1606 bytes .../unique-index/distinct-values-store.cc | Bin 0 -> 1549 bytes .../vectors/unique-index/normal-keys-store.cc | Bin 0 -> 946 bytes tests/vectors/unique-index/refusals.msgpack | Bin 2993 -> 3870 bytes 6 files changed, 34 insertions(+), 9 deletions(-) create mode 100644 tests/vectors/unique-index/cross-schema-store.cc create mode 100644 tests/vectors/unique-index/distinct-values-store.cc create mode 100644 tests/vectors/unique-index/normal-keys-store.cc diff --git a/tests/GameCult.Caching.Tests/UniqueIndexVectorTests.cs b/tests/GameCult.Caching.Tests/UniqueIndexVectorTests.cs index ef5b0b67..23520310 100644 --- a/tests/GameCult.Caching.Tests/UniqueIndexVectorTests.cs +++ b/tests/GameCult.Caching.Tests/UniqueIndexVectorTests.cs @@ -352,6 +352,11 @@ namespace GameCult.Caching.Tests private static readonly Rec[] RepullAfter = Items_(("item:a1", "A"), ("item:b1", "B"), ("item:b2", "B"), ("item:c1", "A")); private static readonly Rec[] KeyCaseRecords = Items_(("a", "S"), ("z", "S"), ("B", "T"), ("C", "T")); private static readonly Rec[] HolderCaseRecords = Items_(("a", "S"), ("A", "S")); + private static readonly Rec[] CrossSchemaRecords = { new("x:1", Alpha, "P"), new("y:1", Beta, "P") }; + // Distinct under any trim, case-fold, NFC or NFKC of the value: 'S' and 's', 'S' and 'S ', NFC and NFD, fullwidth S. + private static readonly Rec[] DistinctValueRecords = Items_( + ("k:1", "S"), ("k:2", "s"), ("k:3", "S "), ("k:4", "\u00E9"), ("k:5", "e\u0301"), ("k:6", "\uFF33"), ("k:7", " S")); + private static readonly Rec[] NormalKeyRecords = Items_(("\u00E9", "S"), ("e\u0301", "S")); private static readonly Rec[] KeyPunctuationRecords = Items_(("_", "S"), ("a", "S"), ("Z", "T"), ("b", "T")); private static readonly Rec[] SurrogateGroupRecords = Items_(("\uE000", "S"), ("\uE000b", "S"), ("\U0001F600", "T"), ("\U0001F600b", "T")); @@ -362,6 +367,8 @@ namespace GameCult.Caching.Tests ("key-case-store.cc", KeyCaseRecords), ("key-punctuation-store.cc", KeyPunctuationRecords), ("surrogate-groups-store.cc", SurrogateGroupRecords), ("holder-case-store.cc", HolderCaseRecords), + ("cross-schema-store.cc", CrossSchemaRecords), ("distinct-values-store.cc", DistinctValueRecords), + ("normal-keys-store.cc", NormalKeyRecords), }; private static readonly Refusal SurrogateRefusal = new("k\U0001F600", "k\uE000", Item, "Sku"); @@ -371,6 +378,7 @@ namespace GameCult.Caching.Tests private static readonly Refusal RepullRefusal = new("item:a1", "item:c1", Item, "Sku"); private static readonly Refusal KeyCaseRefusal = new("B", "C", Item, "Sku"); private static readonly Refusal HolderCaseRefusal = new("A", "a", Item, "Sku"); + private static readonly Refusal NormalKeyRefusal = new("e\u0301", "\u00E9", Item, "Sku"); private static readonly Refusal KeyPunctuationRefusal = new("Z", "b", Item, "Sku"); private static readonly Refusal SurrogateGroupRefusal = new("\U0001F600", "\U0001F600b", Item, "Sku"); @@ -410,6 +418,18 @@ namespace GameCult.Caching.Tests new("load-holders-by-case", new[] { Open("holder-case-store.cc") }, HolderCaseRefusal), // Row 25: the held `a` is replaced only by the exact key `a`, never by `A`. new("batch-replaced-by-case", new[] { Open(), Commit(Items_(("a", "S"))), Commit(Items_(("A", "S"))) }, HolderCaseRefusal), + // Rows 26-31: identity is exact. One value in two schemas is two groups; values differing in case, trailing or leading space, normalisation form or width are two values; keys differing only by normalisation form are two holders. + new("batch-value-in-two-schemas", new[] { Open(), Commit(CrossSchemaRecords) }, null), + new("load-value-in-two-schemas", new[] { Open("cross-schema-store.cc") }, null), + new("batch-distinct-values", new[] { Open(), Commit(DistinctValueRecords) }, null), + new("load-distinct-values", new[] { Open("distinct-values-store.cc") }, null), + new("batch-keys-by-normal-form", new[] { Open(), Commit(NormalKeyRecords) }, NormalKeyRefusal), + new("load-keys-by-normal-form", new[] { Open("normal-keys-store.cc") }, NormalKeyRefusal), + // Rows 32-33: one landing record sharing two unique aliases with a held one; the least alias by code unit is named ('Alt' before 'Sku'; 'Sku' before 'alt'). + new("put-onto-a-held-pair", new[] { Open(), Commit(new Rec("m", Pair, "S", "X")), Commit(new Rec("a", Pair, "S", "X")) }, + new Refusal("a", "m", Pair, "Alt")), + new("put-onto-a-held-alias-pair", new[] { Open(), Commit(new Rec("m", Alias, "S", "X")), Commit(new Rec("a", Alias, "S", "X")) }, + new Refusal("a", "m", Alias, "Sku")), }; private static byte[] RefusalStoreBytes(Rec[] records) @@ -603,7 +623,7 @@ namespace GameCult.Caching.Tests } Assert.That(failed, Is.Empty, "rows that failed"); - Assert.That(walked, Is.EqualTo(25)); + Assert.That(walked, Is.EqualTo(33)); } } } diff --git a/tests/vectors/unique-index/README.md b/tests/vectors/unique-index/README.md index 3f6fd4e9..45e6ef71 100644 --- a/tests/vectors/unique-index/README.md +++ b/tests/vectors/unique-index/README.md @@ -19,7 +19,7 @@ reviewed commit. Other runtimes are held to the same canonical text and the same `" "` and `item:d` holds Sku `U+0085`. Written through a role Sku. - `blank-store.cc`: the same, only `item:c` and `item:d`. - `pair-store.cc`, `surrogate-store.cc`, `types-store.cc`, `repull-before.cc`, `repull-after.cc`, `alias-store.cc`, - `key-case-store.cc`, `key-punctuation-store.cc`, `surrogate-groups-store.cc`, `holder-case-store.cc`: single-file stores written like + `key-case-store.cc`, `key-punctuation-store.cc`, `surrogate-groups-store.cc`, `holder-case-store.cc`, `cross-schema-store.cc`, `distinct-values-store.cc`, `normal-keys-store.cc`: single-file stores written like `duplicate-store.cc`, through roles, for the rows of `refusals.msgpack` that open or pull them. Schemas `tests.unique_item`, `tests.uq_pair` (Sku and Alt both unique), `tests.uq_alias` (Sku and `alt` both unique), `tests.uq_alpha` and `tests.uq_beta`, each slots `[0: Sku string, 1: second string]`, schema version the name plus @@ -51,24 +51,29 @@ last succeeds and the last refuses with exactly the RULE text built from those f byte-identical. Schemas are those of the store files above; `second` is `Category` (plain) except in `tests.uq_pair` and `tests.uq_alias`, where it is `Alt` (unique, alias `Alt` and `alt` respectively). -The 13 rows of the spec are a floor; rows 14-25 extend it with orderings that a code-point, case-insensitive or +The 13 rows of the spec are a floor; rows 14-33 extend it with orderings that a code-point, case-insensitive or culture compare would flip. Rows, in file order: 1 batch-later-value-staged-first, 2 batch-surrogate-keys, 3 batch-interleaved-types, 4 batch-two-aliases, 5 held-then-landing, 6 held-in-the-least-group, 7 swap-off-a-held-value, 8 load-two-aliases, 9 load-surrogate-keys, 10 load-interleaved-types, 11 repull-held-pair, 12 load-repull-after, 13 commit-onto-a-loaded-holder, 14 batch-key-case (`B` before `a`), 15 batch-key-punctuation (`Z` before `_` before `a`), 16 batch-key-case-in-a-group, 17 batch-surrogate-groups (U+1F600 before U+E000, the reverse of code-point order), 18 batch-alias-case (alias `Sku` before `alt`), 19 load-alias-case, 20 load-key-case, 21 load-key-punctuation, 22 -load-surrogate-groups, 23 batch-holders-by-case (`a` and `A` holding one value: `A` before `a`), 24 load-holders-by-case, 25 batch-replaced-by-case (held `a`, then a commit of `A` on its value: `A` does not replace `a`). -Rows 19-22 and 24 open a store holding the records of rows 18, 14, 15, 17 and 23, so a runtime with no batch +load-surrogate-groups, 23 batch-holders-by-case (`a` and `A` holding one value: `A` before `a`), 24 load-holders-by-case, 25 batch-replaced-by-case (held `a`, then a commit of `A` on its value: `A` does not replace `a`), +26 batch-value-in-two-schemas (one value in `tests.uq_alpha` and `tests.uq_beta` at two keys: two groups, success), 27 +load-value-in-two-schemas, 28 batch-distinct-values (`S`, `s`, `S `, ` S`, U+00E9, `e`+U+0301, U+FF33 are seven values: success), 29 +load-distinct-values, 30 batch-keys-by-normal-form (keys U+00E9 and `e`+U+0301 holding one value are two holders, `e`+U+0301 first), +31 load-keys-by-normal-form, 32 put-onto-a-held-pair (held `m` and landing `a` share Sku and Alt in `tests.uq_pair`, one record per +commit: `Alt` is named before `Sku`), 33 put-onto-a-held-alias-pair (the same in `tests.uq_alias`: `Sku` before `alt`). +Rows 19-22, 24, 27, 29 and 31 open a store holding the records of rows 18, 14, 15, 17, 23, 26, 28 and 30, so a runtime with no batch commit still walks alias and cross-group key order. A runtime performs every row its API can perform and asserts the count it walks. These are the counts a runtime must assert, not a statement of which runtimes walk them today: -- C# and Rust: all 25. -- Python: 24, every row but 3 (`put_envelopes` takes one type). -- TypeScript: 12, the rows that only open or pull a store: 5, 8, 9, 10, 11, 12, 13, 19, 20, 21, 22 and 24 (no batch commit, so no - row 1-4, 6, 7, 14-18, 23 or 25; `variants:idea:unique-index-runtimes.ts-batch-commit`). +- C# and Rust: all 33. +- Python: 31, every row but 3 and 26 (`put_envelopes` takes one type, so no batch of two types). +- TypeScript: 18, the rows with no multi-record commit: 5, 8, 9, 10, 11, 12, 13, 19, 20, 21, 22, 24, 25, 27, 29, 31, 32 and 33 + (no batch commit, so no row 1-4, 6, 7, 14-18, 23, 26, 28 or 30; `variants:idea:unique-index-runtimes.ts-batch-commit`). Not pinned: the schema-name tiebreak. Record keys are unique across a whole cache, so two types never hold the same key and two groups can never tie on alias and key pair; the final `schema` comparison cannot decide a refusal. diff --git a/tests/vectors/unique-index/cross-schema-store.cc b/tests/vectors/unique-index/cross-schema-store.cc new file mode 100644 index 0000000000000000000000000000000000000000..1dd2fb667a16ddad28e4e1fb29940d633c09810a GIT binary patch literal 1606 zcmeH{&x#X45XKGoBF@z{^#AV3O+0uLG2%r;s=KR`;AFDSOk{)XLl_s_qmql474-?0 z;7ja2jg@3Kn-E1Icys7Wb=TDQRnM>H{r4~~ngF4X?YbFN*nY#OA1)r&eGuAov;>4C z5OOaxIt>wx=Q;(Zf;C#A^@5q;EDG%s2e2weDZnWPT)O;AgLPB4$FE;PaoC4%+aYPK zm2xgw9)l4{K?KrNDGKh5p!zy;Nm__f2eiTjlPlmh=Sg{wHd>GDvekQ=)zroi9>EZ^ zPPVO;9bBD0!>S&P%AEuMT̑YO)SH$jVj0?yE6nG?T+c_3or@aM7%vdr