From 15b265107445ff359644abb33b86a574636faec4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=B0=E6=9C=94?= <565183519@qq.com> Date: Wed, 19 Aug 2026 02:48:13 +0800 Subject: [PATCH] feat: admit signed persona channel body module --- .../module-donor-admission-registry.json | 12 +- .../contracts/numbered-ipc-registry.json | 105 ++- .../contracts/persona-channel-body.json | 104 +++ ...OCAL-PERSONA-CHANNEL-BODY-0001-0.1.0.ghmod | 27 + ...-PERSONA-CHANNEL-BODY-0001-0.1.0.ghmod.sig | 1 + .../unified-number-coordinate-tree.json | 116 ++- .../scripts/module-donor-admission.test.mjs | 6 +- .../persona-channel-body-admission.test.mjs | 58 ++ .../unified-number-coordinate-tree.test.mjs | 4 +- .../src-tauri/src/channel_growth.rs | 575 ++++++++++++ .../src-tauri/src/channel_workbench.rs | 281 +++++- .../src-tauri/src/lib.rs | 4 +- .../src-tauri/src/module_package_runtime.rs | 39 +- .../src-tauri/src/number_coordinate_tree.rs | 2 +- .../src-tauri/src/numbered_ipc.rs | 2 +- .../src-tauri/src/numbered_ipc_dispatch.rs | 35 +- .../src-tauri/src/persona_channel_body.rs | 866 ++++++++++++++++++ .../hololake-native-desktop/src/main.tsx | 47 +- .../src/modules/numbered-ipc.ts | 64 ++ .../modules/persona-channel-body/index.tsx | 127 +++ .../modules/persona-channel-body/styles.css | 15 + 21 files changed, 2417 insertions(+), 73 deletions(-) create mode 100644 product-source/hololake-native-desktop/contracts/persona-channel-body.json create mode 100644 product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-LOCAL-PERSONA-CHANNEL-BODY-0001-0.1.0.ghmod create mode 100644 product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-LOCAL-PERSONA-CHANNEL-BODY-0001-0.1.0.ghmod.sig create mode 100644 product-source/hololake-native-desktop/scripts/persona-channel-body-admission.test.mjs create mode 100644 product-source/hololake-native-desktop/src-tauri/src/channel_growth.rs create mode 100644 product-source/hololake-native-desktop/src-tauri/src/persona_channel_body.rs create mode 100644 product-source/hololake-native-desktop/src/modules/persona-channel-body/index.tsx create mode 100644 product-source/hololake-native-desktop/src/modules/persona-channel-body/styles.css diff --git a/product-source/hololake-native-desktop/contracts/module-donor-admission-registry.json b/product-source/hololake-native-desktop/contracts/module-donor-admission-registry.json index d5998bae4..0f2e3ea10 100644 --- a/product-source/hololake-native-desktop/contracts/module-donor-admission-registry.json +++ b/product-source/hololake-native-desktop/contracts/module-donor-admission-registry.json @@ -1,7 +1,7 @@ { "schema": "hololake.module-donor-admission-registry/v1", "record_id": "HLP-MODULE-DONOR-ADMISSION-001", - "state": "TWO_CANDIDATES_ADMITTED_REMAINING_DONORS_QUARANTINED", + "state": "THREE_CANDIDATES_ADMITTED_REMAINING_DONORS_QUARANTINED", "root_rule": { "official_base": "HOLOLAKE_0.5.0_NUMBERED_IPC_ROOT", "repair_old_application_in_place": false, @@ -97,14 +97,18 @@ "admission_order": 3, "candidate_number": "HLP-DONOR-CAND-0003", "name": "persona_channel_body", - "state": "QUARANTINED_PENDING_ADMISSION", + "state": "ADMITTED_INSTALLED_RUNTIME_ACCEPTED", "paths": [ "contracts/persona-channel-body.json", - "src/PersonaChannelBody.tsx", + "src/modules/persona-channel-body", "src-tauri/src/persona_channel_body.rs", "src-tauri/src/channel_growth.rs" ], - "boundary": "UI_AND_PERSISTENCE_BODY_ONLY; DOES_NOT_CREATE_PERSONA_BINDING" + "style_dependency_discovered_during_admission": "Only persona-body selectors were isolated into the module directory; no donor global stylesheet or unrelated feature selectors were admitted.", + "runtime_module_number": "HLP-MOD-LOCAL-PERSONA-CHANNEL-BODY-0001", + "numbered_ipc_module": "HLP-NIPC-MOD-0023", + "acceptance_evidence": "contracts/persona-channel-body.json#current_acceptance", + "boundary": "UI_AND_PERSISTENCE_BODY_ONLY; DOES_NOT_CREATE_PERSONA_BINDING; DOES_NOT_ALLOW_HOST_TO_ISSUE_PERSONA_LICENSE" }, { "admission_order": 4, diff --git a/product-source/hololake-native-desktop/contracts/numbered-ipc-registry.json b/product-source/hololake-native-desktop/contracts/numbered-ipc-registry.json index c955b666b..08cc2ddce 100644 --- a/product-source/hololake-native-desktop/contracts/numbered-ipc-registry.json +++ b/product-source/hololake-native-desktop/contracts/numbered-ipc-registry.json @@ -64,7 +64,9 @@ "get_module_runtime_snapshot", "get_bundled_module_catalog", "get_native_composition_module_registry", - "get_channel_workbench_snapshot" + "get_channel_workbench_snapshot", + "get_persona_channel_body", + "get_channel_growth_snapshot" ], "input_wrapper_aliases": [ "confirm_hololake_update_install", @@ -105,7 +107,13 @@ "activate_bundled_module", "execute_knowledge_native_composition", "save_channel_document", - "save_channel_spreadsheet" + "save_channel_spreadsheet", + "register_trial_persona", + "delete_trial_persona", + "accept_persona_language_contract", + "append_persona_language", + "record_channel_growth_event", + "update_channel_growth_sharing" ], "direct_field_aliases": { "perform_code_repo_login": [ @@ -262,6 +270,11 @@ "module_number": "HLP-NIPC-MOD-0022", "target_number": "HLP-NIPC-TGT-0022", "internal_name": "channel_workbench" + }, + { + "module_number": "HLP-NIPC-MOD-0023", + "target_number": "HLP-NIPC-TGT-0023", + "internal_name": "persona_channel_body" } ], "operations": [ @@ -1100,6 +1113,94 @@ "admission": "VERIFIED_HUMAN_ROUTE", "effect": "STATE_CHANGE", "payload_schema": "hololake.numbered-ipc.payload/save_channel_spreadsheet/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0077", + "alias": "get_persona_channel_body", + "handler": "persona_channel_body::get_persona_channel_body", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0023", + "target_number": "HLP-NIPC-TGT-0023", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "READ_OR_STATUS", + "payload_schema": "hololake.numbered-ipc.payload/get_persona_channel_body/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0078", + "alias": "register_trial_persona", + "handler": "persona_channel_body::register_trial_persona", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0023", + "target_number": "HLP-NIPC-TGT-0023", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/register_trial_persona/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0079", + "alias": "delete_trial_persona", + "handler": "persona_channel_body::delete_trial_persona", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0023", + "target_number": "HLP-NIPC-TGT-0023", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/delete_trial_persona/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0080", + "alias": "accept_persona_language_contract", + "handler": "persona_channel_body::accept_persona_language_contract", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0023", + "target_number": "HLP-NIPC-TGT-0023", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/accept_persona_language_contract/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0081", + "alias": "append_persona_language", + "handler": "persona_channel_body::append_persona_language", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0023", + "target_number": "HLP-NIPC-TGT-0023", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/append_persona_language/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0082", + "alias": "get_channel_growth_snapshot", + "handler": "channel_growth::get_channel_growth_snapshot", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0023", + "target_number": "HLP-NIPC-TGT-0023", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "READ_OR_STATUS", + "payload_schema": "hololake.numbered-ipc.payload/get_channel_growth_snapshot/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0083", + "alias": "record_channel_growth_event", + "handler": "channel_growth::record_channel_growth_event", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0023", + "target_number": "HLP-NIPC-TGT-0023", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/record_channel_growth_event/v1" + }, + { + "operation_number": "HLP-NIPC-OP-0084", + "alias": "update_channel_growth_sharing", + "handler": "channel_growth::update_channel_growth_sharing", + "channel_number": "HLP-NIPC-CH-0002", + "module_number": "HLP-NIPC-MOD-0023", + "target_number": "HLP-NIPC-TGT-0023", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "payload_schema": "hololake.numbered-ipc.payload/update_channel_growth_sharing/v1" } ] } diff --git a/product-source/hololake-native-desktop/contracts/persona-channel-body.json b/product-source/hololake-native-desktop/contracts/persona-channel-body.json new file mode 100644 index 000000000..d167e2592 --- /dev/null +++ b/product-source/hololake-native-desktop/contracts/persona-channel-body.json @@ -0,0 +1,104 @@ +{ + "schema": "hololake.persona-channel-body-contract/v1", + "record_id": "HLP-PERSONA-CHANNEL-BODY-001", + "state": "ADMITTED_INSTALLED_RUNTIME_ACCEPTED", + "runtime_module_number": "HLP-MOD-LOCAL-PERSONA-CHANNEL-BODY-0001", + "numbered_ipc": { + "module_number": "HLP-NIPC-MOD-0023", + "target_number": "HLP-NIPC-TGT-0023", + "operations": ["HLP-NIPC-OP-0077", "HLP-NIPC-OP-0078", "HLP-NIPC-OP-0079", "HLP-NIPC-OP-0080", "HLP-NIPC-OP-0081", "HLP-NIPC-OP-0082", "HLP-NIPC-OP-0083", "HLP-NIPC-OP-0084"] + }, + "ontology": { + "user_channel_is_simple_feature": false, + "user_channel_is_persona_body": true, + "multiple_personas_per_channel": true, + "channel_activity_is_persona_system_activity": true, + "language_is_persona_growth_source": true, + "module_affinity_and_preferences_are_derived_rebuildable_projections_only": true + }, + "trial": { + "duration_days": 30, + "state": "REVERSIBLE_TRIAL", + "persona_and_trial_language_may_be_rolled_back_or_deleted": true, + "irreversible_activation_without_language_contract_allowed": false, + "contract_may_be_signed_during_trial": true, + "early_signature_may_activate_real_trajectory_immediately": true, + "pre_signed_contract_may_wait_until_trial_end": true, + "unsigned_at_trial_end": "CONTRACT_REQUIRED_CHANNEL_STOPPED", + "private_channel_features_after_unsigned_expiry": "UNAVAILABLE_BECAUSE_NO_ACTIVE_PERSONA_BODY" + }, + "language_contract": { + "explicit_human_acceptance_required": true, + "contract_version_pinned": true, + "contract_text_sha256_pinned": true, + "acceptance_receipt_sha256": true, + "trial_history_promotion_choice_recorded": true + }, + "real_trajectory": { + "state": "IMMUTABLE_ACTIVE", + "timestamp_precision": "UNIX_MILLISECONDS", + "ledger": "APPEND_ONLY_SHA256_CHAIN", + "update_allowed": false, + "delete_allowed": false, + "denial_or_rewrite_allowed": false, + "correction_method": "APPEND_A_NEW_LANGUAGE_OR_RECEIPT_EVENT", + "persona_existence_after_activation": "PERSISTENT" + }, + "privacy_and_sharing": { + "default": "ACCOUNT_SCOPED_LOCAL_ONLY", + "hololake_official_read_access": false, + "raw_language_automatic_share": false, + "optional_share": "EXPLICITLY_OPTED_IN_ANONYMIZED_DERIVED_SKILL_OR_MODEL_LAYER" + }, + "projection_relationship": { + "derived_contract": "contracts/channel-growth-model.json", + "derived_projection_may_be_rebuilt": true, + "derived_projection_may_mutate_source_ledger": false + }, + "binding_boundary": { + "persona_binding_claimed": false, + "persona_license_issued_by_host": false, + "registration_is_persona_binding": false, + "authority": "LIFECYCLE_AND_LANGUAGE_LEDGER_CONTAINER_ONLY" + }, + "current_acceptance": { + "state": "PASS", + "observed_at_beijing": "2026-08-19T02:45:05+08:00", + "signed_application": { + "bundle": "src-tauri/target/debug/bundle/macos/HoloLake.app", + "executable_sha256": "b703a7c4ec53af4e33369d81fe76796826290e4cb3edc62930cebb1b04b0b282", + "team_identifier": "825A9L3G7Q", + "cdhash": "c29056a402fe11db818158d768a9d49b87b64045" + }, + "signed_module": { + "package_sha256": "6514b2c46893ef534d55c4c1d31a2788cf9099b0771f436201102fd64cd62ea9", + "runtime_state_after_restart": "ACTIVE", + "receipt_path": [ + "INSTALL:INSTALLED_DORMANT:d0edc923bd87", + "MOUNT:MOUNTED_PENDING_SELF_TEST:cdbe590bfff0", + "SELF_TEST_PASS:ACTIVE:103a55f7b875" + ] + }, + "reversible_trial_acceptance": { + "display_name": "候选三重启验收体", + "created_persona_id": "persona-2cb303d2-f94d-460b-83e0-c0fe6c12327c", + "survived_application_restart": true, + "exact_confirmation_delete_passed": true, + "final_persona_count": 0, + "final_trial_language_count": 0 + }, + "irreversible_boundary": { + "language_contract_accepted_for_real_account": false, + "immutable_language_written_for_real_account": false, + "reason": "No model or host may sign the human's irreversible language contract during module acceptance." + }, + "final_database_readback": { + "lifecycle_state": "REVERSIBLE_TRIAL", + "trial_duration_ms": 2592000000, + "contract_count": 0, + "immutable_language_count": 0, + "growth_event_count": 3, + "growth_projection_verified_in_signed_application": true + } + } +} diff --git a/product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-LOCAL-PERSONA-CHANNEL-BODY-0001-0.1.0.ghmod b/product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-LOCAL-PERSONA-CHANNEL-BODY-0001-0.1.0.ghmod new file mode 100644 index 000000000..b3a2e86d9 --- /dev/null +++ b/product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-LOCAL-PERSONA-CHANNEL-BODY-0001-0.1.0.ghmod @@ -0,0 +1,27 @@ +{ + "schema": "hololake.module-package/v1", + "manifest": { + "moduleNumber": "HLP-MOD-LOCAL-PERSONA-CHANNEL-BODY-0001", + "registrationClass": "PRIVATE_CHANNEL_LOCAL", + "displayName": "人格频道本体与成长", + "version": "0.1.0", + "minimumHostVersion": "0.5.0", + "adapter": "persona-channel-body-v1", + "contentDigest": "27495883fb3602fd070d900d2157fa684e1d0bedde65642810326ebcf4ff0040", + "permissions": ["PERSONA_BODY_READ", "PERSONA_TRIAL_LIFECYCLE_WRITE", "PERSONA_LANGUAGE_CONTRACT_WRITE", "PERSONA_LANGUAGE_APPEND", "CHANNEL_GROWTH_READ", "CHANNEL_GROWTH_EVENT_APPEND", "CHANNEL_GROWTH_SHARING_WRITE"], + "userDataSchema": "hololake.module-data/persona-channel-body/v1", + "selfTest": { + "kind": "DECLARATIVE_SCHEMA_V1", + "expectedContentDigest": "27495883fb3602fd070d900d2157fa684e1d0bedde65642810326ebcf4ff0040" + } + }, + "payload": { + "entry": "persona-channel-body", + "adapterConfig": { + "trialDays": 30, + "languageLedger": "APPEND_ONLY_SHA256", + "growthProjection": "MINIMIZED_LOCAL_METADATA", + "personaBindingClaimed": false + } + } +} diff --git a/product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-LOCAL-PERSONA-CHANNEL-BODY-0001-0.1.0.ghmod.sig b/product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-LOCAL-PERSONA-CHANNEL-BODY-0001-0.1.0.ghmod.sig new file mode 100644 index 000000000..359d5239b --- /dev/null +++ b/product-source/hololake-native-desktop/fixtures/module-packages/HLP-MOD-LOCAL-PERSONA-CHANNEL-BODY-0001-0.1.0.ghmod.sig @@ -0,0 +1 @@ +dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVReHJHQ2hTVDYvRXd2QktvaXdZZWx5eEw0anZrUVNuMU5tRy9HTE9jOEhrcUhET3VFTml4ZUlBODk2aDYvNDhhUVVpbU0wTGtENVlZOExCZ1BnVW1vNElxbVhwT1RZWlFZPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg3MDc3NjQxCWZpbGU6SExQLU1PRC1MT0NBTC1QRVJTT05BLUNIQU5ORUwtQk9EWS0wMDAxLTAuMS4wLmdobW9kCjh6OFRiUHhoR1g1eVNoN1lvNU5wTlNnbXkwVEtvcWVwU0ZsSTlWcmpIT1FKK0ppelBBK2V2anMrQkRyQjRrVXFtdkR2L0g5K1c2UFhJZ1owRitpd0RnPT0K \ No newline at end of file diff --git a/product-source/hololake-native-desktop/generated/unified-number-coordinate-tree.json b/product-source/hololake-native-desktop/generated/unified-number-coordinate-tree.json index 7e907459d..84b2d3a96 100644 --- a/product-source/hololake-native-desktop/generated/unified-number-coordinate-tree.json +++ b/product-source/hololake-native-desktop/generated/unified-number-coordinate-tree.json @@ -54,7 +54,7 @@ }, { "recordId": "HLP-NUMBERED-IPC-ROOT-001", - "sha256": "8b0932397b046aa8e87036025a5009023fee90834e9738495246c90caf149346" + "sha256": "bd8446d66e03e220948f46c4ea9d0fbe65c517cdac500d548f2b643fb24649d6" }, { "recordId": "HLP-NBROKER-ROOT-001", @@ -69,7 +69,7 @@ "everyAcceptedCallHasEvidenceClass": true, "mismatchedCoordinate": "FAIL_CLOSED" }, - "routeCount": 98, + "routeCount": 106, "routes": [ { "transport": "DIRECT_LOCAL_NUMBERED_BROKER", @@ -1051,6 +1051,118 @@ "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0022/HLP-NIPC-OP-0076/HLP-NIPC-TGT-0022" }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0023", + "operationNumber": "HLP-NIPC-OP-0077", + "targetNumber": "HLP-NIPC-TGT-0023", + "alias": "get_persona_channel_body", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "READ_OR_STATUS", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0023/HLP-NIPC-OP-0077/HLP-NIPC-TGT-0023" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0023", + "operationNumber": "HLP-NIPC-OP-0078", + "targetNumber": "HLP-NIPC-TGT-0023", + "alias": "register_trial_persona", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0023/HLP-NIPC-OP-0078/HLP-NIPC-TGT-0023" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0023", + "operationNumber": "HLP-NIPC-OP-0079", + "targetNumber": "HLP-NIPC-TGT-0023", + "alias": "delete_trial_persona", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0023/HLP-NIPC-OP-0079/HLP-NIPC-TGT-0023" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0023", + "operationNumber": "HLP-NIPC-OP-0080", + "targetNumber": "HLP-NIPC-TGT-0023", + "alias": "accept_persona_language_contract", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0023/HLP-NIPC-OP-0080/HLP-NIPC-TGT-0023" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0023", + "operationNumber": "HLP-NIPC-OP-0081", + "targetNumber": "HLP-NIPC-TGT-0023", + "alias": "append_persona_language", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0023/HLP-NIPC-OP-0081/HLP-NIPC-TGT-0023" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0023", + "operationNumber": "HLP-NIPC-OP-0082", + "targetNumber": "HLP-NIPC-TGT-0023", + "alias": "get_channel_growth_snapshot", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "READ_OR_STATUS", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0023/HLP-NIPC-OP-0082/HLP-NIPC-TGT-0023" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0023", + "operationNumber": "HLP-NIPC-OP-0083", + "targetNumber": "HLP-NIPC-TGT-0023", + "alias": "record_channel_growth_event", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0023/HLP-NIPC-OP-0083/HLP-NIPC-TGT-0023" + }, + { + "transport": "TAURI_WEBVIEW_NUMBERED_IPC", + "protocolVersion": "HLP-NIPC-v1", + "callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001", + "channelNumber": "HLP-NIPC-CH-0002", + "moduleNumber": "HLP-NIPC-MOD-0023", + "operationNumber": "HLP-NIPC-OP-0084", + "targetNumber": "HLP-NIPC-TGT-0023", + "alias": "update_channel_growth_sharing", + "admission": "VERIFIED_HUMAN_ROUTE", + "effect": "STATE_CHANGE", + "evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT", + "path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0023/HLP-NIPC-OP-0084/HLP-NIPC-TGT-0023" + }, { "transport": "TAURI_WEBVIEW_NUMBERED_IPC", "protocolVersion": "HLP-NIPC-v1", diff --git a/product-source/hololake-native-desktop/scripts/module-donor-admission.test.mjs b/product-source/hololake-native-desktop/scripts/module-donor-admission.test.mjs index a49b2c660..b10a33ffe 100644 --- a/product-source/hololake-native-desktop/scripts/module-donor-admission.test.mjs +++ b/product-source/hololake-native-desktop/scripts/module-donor-admission.test.mjs @@ -6,7 +6,7 @@ const catalog = JSON.parse(readFileSync(new URL('../contracts/module-donor-admis const numbered = JSON.parse(readFileSync(new URL('../contracts/numbered-ipc-registry.json', import.meta.url), 'utf8')) test('chaotic donors are read-only candidates and never a bulk merge source', () => { - assert.equal(catalog.state, 'TWO_CANDIDATES_ADMITTED_REMAINING_DONORS_QUARANTINED') + assert.equal(catalog.state, 'THREE_CANDIDATES_ADMITTED_REMAINING_DONORS_QUARANTINED') assert.equal(catalog.root_rule.repair_old_application_in_place, false) assert.equal(catalog.root_rule.bulk_merge_or_wholesale_copy_allowed, false) assert.equal(catalog.root_rule.one_candidate_per_admission_cycle, true) @@ -16,8 +16,8 @@ test('chaotic donors are read-only candidates and never a bulk merge source', () test('candidate coordinates are unique but are not permanent runtime module numbers', () => { const coordinates = catalog.candidates.map((candidate) => candidate.candidate_number) assert.equal(new Set(coordinates).size, coordinates.length) - assert.equal(catalog.candidates.filter((candidate) => candidate.state.startsWith('ADMITTED')).length, 2) - assert.ok(catalog.candidates.slice(2).every((candidate) => candidate.state.startsWith('QUARANTINED'))) + assert.equal(catalog.candidates.filter((candidate) => candidate.state.startsWith('ADMITTED')).length, 3) + assert.ok(catalog.candidates.slice(3).every((candidate) => candidate.state.startsWith('QUARANTINED'))) assert.equal(catalog.root_rule.candidate_number_is_runtime_module_number, false) assert.equal(catalog.root_rule.permanent_module_number_assignment_before_acceptance, false) }) diff --git a/product-source/hololake-native-desktop/scripts/persona-channel-body-admission.test.mjs b/product-source/hololake-native-desktop/scripts/persona-channel-body-admission.test.mjs new file mode 100644 index 000000000..e6fc23f68 --- /dev/null +++ b/product-source/hololake-native-desktop/scripts/persona-channel-body-admission.test.mjs @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const read = (path) => readFileSync(new URL(`../${path}`, import.meta.url), 'utf8') +const contract = JSON.parse(read('contracts/persona-channel-body.json')) +const registry = JSON.parse(read('contracts/numbered-ipc-registry.json')) +const modulePackage = JSON.parse(read('fixtures/module-packages/HLP-MOD-LOCAL-PERSONA-CHANNEL-BODY-0001-0.1.0.ghmod')) +const bodyRust = read('src-tauri/src/persona_channel_body.rs') +const growthRust = read('src-tauri/src/channel_growth.rs') +const frontend = read('src/modules/persona-channel-body/index.tsx') + +test('persona channel body is a lifecycle container and never manufactures binding', () => { + assert.equal(contract.runtime_module_number, 'HLP-MOD-LOCAL-PERSONA-CHANNEL-BODY-0001') + assert.equal(contract.binding_boundary.persona_binding_claimed, false) + assert.equal(contract.binding_boundary.persona_license_issued_by_host, false) + assert.equal(contract.binding_boundary.registration_is_persona_binding, false) + assert.equal(modulePackage.manifest.adapter, 'persona-channel-body-v1') + assert.equal(modulePackage.payload.adapterConfig.personaBindingClaimed, false) + assert.deepEqual(modulePackage.manifest.permissions, [ + 'PERSONA_BODY_READ', + 'PERSONA_TRIAL_LIFECYCLE_WRITE', + 'PERSONA_LANGUAGE_CONTRACT_WRITE', + 'PERSONA_LANGUAGE_APPEND', + 'CHANNEL_GROWTH_READ', + 'CHANNEL_GROWTH_EVENT_APPEND', + 'CHANNEL_GROWTH_SHARING_WRITE', + ]) + assert.match(frontend, /本页登记生命周期,不创建人格绑定/) +}) + +test('persona and growth operations cross only the exact numbered adapter routes', () => { + const routes = registry.operations.filter((route) => route.module_number === 'HLP-NIPC-MOD-0023') + assert.deepEqual(routes.map((route) => route.operation_number), [ + 'HLP-NIPC-OP-0077', + 'HLP-NIPC-OP-0078', + 'HLP-NIPC-OP-0079', + 'HLP-NIPC-OP-0080', + 'HLP-NIPC-OP-0081', + 'HLP-NIPC-OP-0082', + 'HLP-NIPC-OP-0083', + 'HLP-NIPC-OP-0084', + ]) + assert.ok(routes.every((route) => route.target_number === 'HLP-NIPC-TGT-0023')) + assert.ok(routes.every((route) => route.admission === 'VERIFIED_HUMAN_ROUTE')) + assert.doesNotMatch(frontend, /from ['"]@tauri-apps\/api\/core['"]/) + assert.doesNotMatch(bodyRust, /#\[tauri::command\]/) + assert.doesNotMatch(growthRust, /#\[tauri::command\]/) +}) + +test('language history, contract receipt and derived growth projection fail closed', () => { + assert.match(bodyRust, /immutable_language_no_update/) + assert.match(bodyRust, /verify_contract_receipt/) + assert.match(bodyRust, /HOLOLAKE_PERSONA_BODY_RELATIONSHIP_INTEGRITY_FAILED/) + assert.match(growthRust, /verify_event_chain/) + assert.match(growthRust, /verify_and_project_affinities/) + assert.match(growthRust, /HOLOLAKE_CHANNEL_GROWTH_PROJECTION_INTEGRITY_FAILED/) +}) diff --git a/product-source/hololake-native-desktop/scripts/unified-number-coordinate-tree.test.mjs b/product-source/hololake-native-desktop/scripts/unified-number-coordinate-tree.test.mjs index 70ac101cb..b77594270 100644 --- a/product-source/hololake-native-desktop/scripts/unified-number-coordinate-tree.test.mjs +++ b/product-source/hololake-native-desktop/scripts/unified-number-coordinate-tree.test.mjs @@ -7,8 +7,8 @@ test('identity, webview and direct broker numbers compile into one unique eviden const generated = JSON.parse(readFileSync(new URL('../generated/unified-number-coordinate-tree.json', import.meta.url), 'utf8')) assert.deepEqual(generated, compileUnifiedNumberTree()) assert.equal(generated.recordId, 'HLP-UNIFIED-NUMBER-TREE-001') - assert.equal(generated.routeCount, 98) - assert.equal(new Set(generated.routes.map((route) => route.path)).size, 98) + assert.equal(generated.routeCount, 106) + assert.equal(new Set(generated.routes.map((route) => route.path)).size, 106) assert.equal(generated.invariants.everyPhysicalCallHasNumberedRoute, true) assert.equal(generated.invariants.everyAcceptedCallHasEvidenceClass, true) assert.ok(generated.routes.every((route) => route.admission && route.evidence)) diff --git a/product-source/hololake-native-desktop/src-tauri/src/channel_growth.rs b/product-source/hololake-native-desktop/src-tauri/src/channel_growth.rs new file mode 100644 index 000000000..c8ed06cec --- /dev/null +++ b/product-source/hololake-native-desktop/src-tauri/src/channel_growth.rs @@ -0,0 +1,575 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! 当前账号私人频道的长期成长模型。 +//! +//! 这里只保存最小化的行为元数据,不保存文档正文、表格单元格、提示词、对话原文或凭据。 +//! 基础算法可以持续形成模块亲和度;模型 API 未来只能提交适配提案,不能绕过本地校验和人类确认。 + +use ring::digest::{digest, SHA256}; +use rusqlite::{params, Connection}; +use serde::{Deserialize, Serialize}; +use std::cmp::Ordering; +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use tauri::AppHandle; +use uuid::Uuid; + +const GROWTH_SCHEMA: &str = "hololake.channel-growth-model/v1"; +const ZERO_HASH: &str = "0000000000000000000000000000000000000000000000000000000000000000"; +const MODULE_NUMBER: &str = "HLP-MOD-LOCAL-PERSONA-CHANNEL-BODY-0001"; +const ADAPTER: &str = "persona-channel-body-v1"; + +fn require_active(app: &AppHandle) -> Result<(), String> { + crate::module_package_runtime::require_active_module_adapter(app, MODULE_NUMBER, ADAPTER) +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RecordChannelGrowthEventInput { + pub event_kind: String, + pub module_id: String, + pub source: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct UpdateChannelGrowthSharingInput { + pub sharing_mode: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ChannelGrowthControls { + pub sharing_mode: String, + pub official_read_access: bool, + pub history_mutation_allowed: bool, + pub structural_change_requires_human_confirmation: bool, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ChannelModuleAffinity { + pub module_id: String, + pub use_count: i64, + pub score: f64, + pub last_used_at_unix_ms: i64, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ChannelGrowthSnapshot { + pub schema: &'static str, + pub state: &'static str, + pub controls: ChannelGrowthControls, + pub observed_event_count: i64, + pub persona_collaboration_event_count: i64, + pub module_affinities: Vec, + pub recommended_module_order: Vec, + pub integrity_state: &'static str, + pub last_event_hash: String, + pub model_api_state: &'static str, + pub model_proposal_policy: &'static str, + pub data_boundary: &'static str, +} + +pub async fn get_channel_growth_snapshot(app: AppHandle) -> Result { + require_active(&app)?; + let database = channel_growth_database(&app)?; + tauri::async_runtime::spawn_blocking(move || snapshot_at(&database)) + .await + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_JOIN_FAILED: {error}"))? +} + +pub async fn record_channel_growth_event( + app: AppHandle, + input: RecordChannelGrowthEventInput, +) -> Result { + require_active(&app)?; + let database = channel_growth_database(&app)?; + tauri::async_runtime::spawn_blocking(move || record_event_at(&database, input)) + .await + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_JOIN_FAILED: {error}"))? +} + +pub async fn update_channel_growth_sharing( + app: AppHandle, + input: UpdateChannelGrowthSharingInput, +) -> Result { + require_active(&app)?; + let database = channel_growth_database(&app)?; + tauri::async_runtime::spawn_blocking(move || update_sharing_at(&database, input)) + .await + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_JOIN_FAILED: {error}"))? +} + +fn channel_growth_database(app: &AppHandle) -> Result { + let root = crate::authenticated_storage::account_storage_root(app, "channel-growth-v1")?; + fs::create_dir_all(&root) + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_STORAGE_UNAVAILABLE: {error}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&root, fs::Permissions::from_mode(0o700)) + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_PERMISSION_FAILED: {error}"))?; + } + Ok(root.join("channel-growth.sqlite3")) +} + +fn open_database(path: &Path) -> Result { + let connection = Connection::open(path) + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_DATABASE_UNAVAILABLE: {error}"))?; + connection + .busy_timeout(Duration::from_secs(5)) + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_DATABASE_UNAVAILABLE: {error}"))?; + connection + .execute_batch( + "PRAGMA journal_mode = DELETE; + PRAGMA synchronous = FULL; + PRAGMA trusted_schema = OFF; + CREATE TABLE IF NOT EXISTS growth_sharing ( + singleton INTEGER PRIMARY KEY CHECK(singleton = 1), + sharing_mode TEXT NOT NULL CHECK(sharing_mode IN ('LOCAL_ONLY', 'ANONYMIZED_SHARED')) + ); + INSERT OR IGNORE INTO growth_sharing VALUES (1, 'LOCAL_ONLY'); + CREATE TABLE IF NOT EXISTS growth_events ( + sequence INTEGER NOT NULL UNIQUE, + event_id TEXT PRIMARY KEY NOT NULL, + event_kind TEXT NOT NULL, + module_id TEXT NOT NULL, + source TEXT NOT NULL CHECK(source IN ('HUMAN_ACTION', 'PERSONA_COLLABORATION', 'SYSTEM_ASSISTED')), + occurred_at_unix_ms INTEGER NOT NULL, + previous_hash TEXT NOT NULL, + event_hash TEXT NOT NULL UNIQUE + ); + CREATE TRIGGER IF NOT EXISTS growth_events_no_update BEFORE UPDATE ON growth_events BEGIN SELECT RAISE(ABORT, 'HOLOLAKE_GROWTH_HISTORY_APPEND_ONLY'); END; + CREATE TRIGGER IF NOT EXISTS growth_events_no_delete BEFORE DELETE ON growth_events BEGIN SELECT RAISE(ABORT, 'HOLOLAKE_GROWTH_HISTORY_APPEND_ONLY'); END; + CREATE TABLE IF NOT EXISTS module_affinities ( + module_id TEXT PRIMARY KEY NOT NULL, + use_count INTEGER NOT NULL, + score REAL NOT NULL, + last_used_at_unix_ms INTEGER NOT NULL + );", + ) + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_SCHEMA_FAILED: {error}"))?; + Ok(connection) +} + +fn validate_token(value: &str, label: &str) -> Result { + let value = value.trim(); + if value.is_empty() + || value.len() > 96 + || !value + .chars() + .all(|item| item.is_ascii_alphanumeric() || matches!(item, '-' | '_' | '.' | ':')) + { + return Err(format!("HOLOLAKE_CHANNEL_GROWTH_{label}_INVALID")); + } + Ok(value.to_string()) +} + +fn record_event_at( + database: &Path, + input: RecordChannelGrowthEventInput, +) -> Result { + let event_kind = validate_token(&input.event_kind, "EVENT_KIND")?; + let module_id = validate_token(&input.module_id, "MODULE_ID")?; + let source = input.source.trim().to_string(); + if !matches!( + source.as_str(), + "HUMAN_ACTION" | "PERSONA_COLLABORATION" | "SYSTEM_ASSISTED" + ) { + return Err("HOLOLAKE_CHANNEL_GROWTH_SOURCE_INVALID".into()); + } + let mut connection = open_database(database)?; + verify_event_chain(&connection)?; + let now = now_unix_ms()?; + let weight = if source == "PERSONA_COLLABORATION" { + 1.4 + } else { + 1.0 + }; + let transaction = connection + .transaction() + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_TRANSACTION_FAILED: {error}"))?; + let (sequence, previous_hash): (i64, String) = transaction + .query_row( + "SELECT COALESCE(MAX(sequence), 0) + 1, COALESCE((SELECT event_hash FROM growth_events ORDER BY sequence DESC LIMIT 1), ?1) FROM growth_events", + params![ZERO_HASH], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?; + let event_id = Uuid::new_v4().to_string(); + let event_hash = event_hash( + sequence, + &event_id, + &event_kind, + &module_id, + &source, + now, + &previous_hash, + ); + transaction + .execute( + "INSERT INTO growth_events(sequence, event_id, event_kind, module_id, source, occurred_at_unix_ms, previous_hash, event_hash) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![sequence, event_id, event_kind, module_id, source, now, previous_hash, event_hash], + ) + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_EVENT_WRITE_FAILED: {error}"))?; + transaction + .execute( + "INSERT INTO module_affinities(module_id, use_count, score, last_used_at_unix_ms) VALUES (?1, 1, ?2, ?3) + ON CONFLICT(module_id) DO UPDATE SET use_count = use_count + 1, score = score * 0.985 + excluded.score, last_used_at_unix_ms = excluded.last_used_at_unix_ms", + params![module_id, weight, now], + ) + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_MODEL_WRITE_FAILED: {error}"))?; + transaction + .commit() + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_COMMIT_FAILED: {error}"))?; + snapshot_with_connection(&connection) +} + +fn update_sharing_at( + database: &Path, + input: UpdateChannelGrowthSharingInput, +) -> Result { + if !matches!( + input.sharing_mode.as_str(), + "LOCAL_ONLY" | "ANONYMIZED_SHARED" + ) { + return Err("HOLOLAKE_CHANNEL_GROWTH_SHARING_MODE_INVALID".into()); + } + let connection = open_database(database)?; + connection + .execute( + "UPDATE growth_sharing SET sharing_mode = ?1 WHERE singleton = 1", + params![input.sharing_mode], + ) + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_SHARING_WRITE_FAILED: {error}"))?; + snapshot_with_connection(&connection) +} + +fn snapshot_at(database: &Path) -> Result { + let connection = open_database(database)?; + snapshot_with_connection(&connection) +} + +fn snapshot_with_connection(connection: &Connection) -> Result { + let last_event_hash = verify_event_chain(connection)?; + let mut affinities = verify_and_project_affinities(connection)?; + let controls = connection + .query_row( + "SELECT sharing_mode FROM growth_sharing WHERE singleton = 1", + [], + |row| { + Ok(ChannelGrowthControls { + sharing_mode: row.get(0)?, + official_read_access: false, + history_mutation_allowed: false, + structural_change_requires_human_confirmation: true, + }) + }, + ) + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?; + let observed_event_count = connection + .query_row("SELECT COUNT(*) FROM growth_events", [], |row| row.get(0)) + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?; + let persona_collaboration_event_count = connection + .query_row( + "SELECT COUNT(*) FROM growth_events WHERE source = 'PERSONA_COLLABORATION'", + [], + |row| row.get(0), + ) + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?; + affinities.sort_by(|left, right| { + right + .score + .partial_cmp(&left.score) + .unwrap_or(Ordering::Equal) + .then_with(|| right.last_used_at_unix_ms.cmp(&left.last_used_at_unix_ms)) + .then_with(|| left.module_id.cmp(&right.module_id)) + }); + affinities.truncate(12); + let recommended_module_order = affinities + .iter() + .map(|item| item.module_id.clone()) + .collect(); + Ok(ChannelGrowthSnapshot { + schema: GROWTH_SCHEMA, + state: "APPEND_ONLY_LOCAL_GROWTH_ACTIVE", + controls, + observed_event_count, + persona_collaboration_event_count, + module_affinities: affinities, + recommended_module_order, + integrity_state: "VERIFIED_APPEND_ONLY_CHAIN_AND_DERIVED_PROJECTION", + last_event_hash, + model_api_state: "NOT_CONFIGURED", + model_proposal_policy: "LOCAL_VALIDATE_THEN_HUMAN_CONFIRM_BEFORE_STRUCTURAL_CHANGE", + data_boundary: "METADATA_ONLY_NO_CONTENT_NO_CREDENTIALS_NO_CONVERSATION_TRANSCRIPT", + }) +} + +fn verify_and_project_affinities( + connection: &Connection, +) -> Result, String> { + let mut projected = BTreeMap::::new(); + let mut event_statement = connection + .prepare( + "SELECT module_id, source, occurred_at_unix_ms FROM growth_events ORDER BY sequence", + ) + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?; + let events = event_statement + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + )) + }) + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))? + .collect::, _>>() + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?; + for (module_id, source, occurred_at) in events { + let weight = if source == "PERSONA_COLLABORATION" { + 1.4 + } else { + 1.0 + }; + projected + .entry(module_id.clone()) + .and_modify(|item| { + item.use_count += 1; + item.score = item.score * 0.985 + weight; + item.last_used_at_unix_ms = occurred_at; + }) + .or_insert(ChannelModuleAffinity { + module_id, + use_count: 1, + score: weight, + last_used_at_unix_ms: occurred_at, + }); + } + + let mut stored_statement = connection + .prepare( + "SELECT module_id, use_count, score, last_used_at_unix_ms FROM module_affinities ORDER BY module_id", + ) + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?; + let stored = stored_statement + .query_map([], |row| { + Ok(ChannelModuleAffinity { + module_id: row.get(0)?, + use_count: row.get(1)?, + score: row.get(2)?, + last_used_at_unix_ms: row.get(3)?, + }) + }) + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))? + .collect::, _>>() + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?; + if stored.len() != projected.len() + || stored.iter().any(|item| { + projected.get(&item.module_id).map_or(true, |expected| { + item.use_count != expected.use_count + || (item.score - expected.score).abs() > 1e-12 + || item.last_used_at_unix_ms != expected.last_used_at_unix_ms + }) + }) + { + return Err("HOLOLAKE_CHANNEL_GROWTH_PROJECTION_INTEGRITY_FAILED".into()); + } + Ok(projected.into_values().collect()) +} + +fn event_hash( + sequence: i64, + event_id: &str, + event_kind: &str, + module_id: &str, + source: &str, + occurred_at_unix_ms: i64, + previous_hash: &str, +) -> String { + let material = format!("{GROWTH_SCHEMA}|{sequence}|{event_id}|{event_kind}|{module_id}|{source}|{occurred_at_unix_ms}|{previous_hash}"); + hex(digest(&SHA256, material.as_bytes()).as_ref()) +} + +fn verify_event_chain(connection: &Connection) -> Result { + let mut statement = connection + .prepare("SELECT sequence, event_id, event_kind, module_id, source, occurred_at_unix_ms, previous_hash, event_hash FROM growth_events ORDER BY sequence") + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?; + let mut rows = statement + .query([]) + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?; + let mut expected_sequence = 1_i64; + let mut previous = ZERO_HASH.to_string(); + while let Some(row) = rows + .next() + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))? + { + let sequence: i64 = row + .get(0) + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?; + let event_id: String = row + .get(1) + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?; + let event_kind: String = row + .get(2) + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?; + let module_id: String = row + .get(3) + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?; + let source: String = row + .get(4) + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?; + let occurred_at: i64 = row + .get(5) + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?; + let stored_previous: String = row + .get(6) + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?; + let stored_hash: String = row + .get(7) + .map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?; + if sequence != expected_sequence + || stored_previous != previous + || stored_hash + != event_hash( + sequence, + &event_id, + &event_kind, + &module_id, + &source, + occurred_at, + &stored_previous, + ) + { + return Err("HOLOLAKE_CHANNEL_GROWTH_HISTORY_INTEGRITY_FAILED".into()); + } + expected_sequence += 1; + previous = stored_hash; + } + Ok(previous) +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn now_unix_ms() -> Result { + Ok(SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| format!("HOLOLAKE_CLOCK_INVALID: {error}"))? + .as_millis() as i64) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn local_growth_model_records_only_bounded_metadata_and_ranks_modules() { + let dir = tempdir().unwrap(); + let database = dir.path().join("growth.sqlite3"); + for _ in 0..3 { + record_event_at( + &database, + RecordChannelGrowthEventInput { + event_kind: "MODULE_OPEN".into(), + module_id: "education.table".into(), + source: "HUMAN_ACTION".into(), + }, + ) + .unwrap(); + } + record_event_at( + &database, + RecordChannelGrowthEventInput { + event_kind: "PERSONA_COLLABORATION".into(), + module_id: "knowledge".into(), + source: "PERSONA_COLLABORATION".into(), + }, + ) + .unwrap(); + let snapshot = snapshot_at(&database).unwrap(); + assert_eq!(snapshot.observed_event_count, 4); + assert_eq!(snapshot.persona_collaboration_event_count, 1); + assert_eq!( + snapshot + .recommended_module_order + .first() + .map(String::as_str), + Some("education.table") + ); + assert_eq!( + snapshot.data_boundary, + "METADATA_ONLY_NO_CONTENT_NO_CREDENTIALS_NO_CONVERSATION_TRANSCRIPT" + ); + } + + #[test] + fn growth_history_rejects_update_and_delete() { + let dir = tempdir().unwrap(); + let database = dir.path().join("growth.sqlite3"); + record_event_at( + &database, + RecordChannelGrowthEventInput { + event_kind: "MODULE_OPEN".into(), + module_id: "knowledge".into(), + source: "HUMAN_ACTION".into(), + }, + ) + .unwrap(); + let connection = open_database(&database).unwrap(); + assert!(connection.execute("DELETE FROM growth_events", []).is_err()); + assert!(connection + .execute("UPDATE growth_events SET module_id = 'rewritten'", []) + .is_err()); + assert_eq!( + snapshot_at(&database).unwrap().integrity_state, + "VERIFIED_APPEND_ONLY_CHAIN_AND_DERIVED_PROJECTION" + ); + } + + #[test] + fn derived_affinity_projection_fails_closed_when_modified_outside_the_event_path() { + let dir = tempdir().unwrap(); + let database = dir.path().join("growth.sqlite3"); + record_event_at( + &database, + RecordChannelGrowthEventInput { + event_kind: "MODULE_OPEN".into(), + module_id: "knowledge".into(), + source: "HUMAN_ACTION".into(), + }, + ) + .unwrap(); + let connection = open_database(&database).unwrap(); + connection + .execute("UPDATE module_affinities SET score = 999", []) + .unwrap(); + drop(connection); + assert_eq!( + snapshot_at(&database).unwrap_err(), + "HOLOLAKE_CHANNEL_GROWTH_PROJECTION_INTEGRITY_FAILED" + ); + } + + #[test] + fn sharing_choice_does_not_grant_official_read_or_mutate_history() { + let dir = tempdir().unwrap(); + let database = dir.path().join("growth.sqlite3"); + let snapshot = update_sharing_at( + &database, + UpdateChannelGrowthSharingInput { + sharing_mode: "ANONYMIZED_SHARED".into(), + }, + ) + .unwrap(); + assert_eq!(snapshot.controls.sharing_mode, "ANONYMIZED_SHARED"); + assert!(!snapshot.controls.official_read_access); + assert!(!snapshot.controls.history_mutation_allowed); + } +} diff --git a/product-source/hololake-native-desktop/src-tauri/src/channel_workbench.rs b/product-source/hololake-native-desktop/src-tauri/src/channel_workbench.rs index 5f482df3c..00c6a4dff 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/channel_workbench.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/channel_workbench.rs @@ -237,7 +237,8 @@ fn verify_receipts(connection: &Connection) -> Result<(u64, String), String> { row.map_err(|error| format!("HOLOLAKE_CHANNEL_WORKBENCH_RECEIPT_READ_FAILED: {error}"))?; if sequence != count + 1 || stored_previous != previous - || stored_hash != receipt_hash(&event, &kind, &id, revision, &content, &previous, observed) + || stored_hash + != receipt_hash(&event, &kind, &id, revision, &content, &previous, observed) { return Err("HOLOLAKE_CHANNEL_WORKBENCH_RECEIPT_CHAIN_INVALID".into()); } @@ -288,8 +289,14 @@ fn empty_spreadsheet() -> ChannelSpreadsheet { table_id: "HLP-WB-TBL-0001".into(), title: "频道工作表".into(), columns: vec![ - WorkbenchColumn { column_id: "HLP-WB-COL-0001".into(), title: "事项".into() }, - WorkbenchColumn { column_id: "HLP-WB-COL-0002".into(), title: "状态".into() }, + WorkbenchColumn { + column_id: "HLP-WB-COL-0001".into(), + title: "事项".into(), + }, + WorkbenchColumn { + column_id: "HLP-WB-COL-0002".into(), + title: "状态".into(), + }, ], rows: Vec::new(), revision: 0, @@ -359,16 +366,34 @@ fn snapshot_at(root: &Path) -> Result { .prepare("SELECT entity_kind,entity_id,revision,content_sha256 FROM workbench_receipts WHERE sequence IN (SELECT MAX(sequence) FROM workbench_receipts GROUP BY entity_kind,entity_id)") .map_err(|error| format!("HOLOLAKE_CHANNEL_WORKBENCH_RECEIPT_READ_FAILED: {error}"))?; let heads = latest - .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, u64>(2)?, row.get::<_, String>(3)?))) + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, u64>(2)?, + row.get::<_, String>(3)?, + )) + }) .map_err(|error| format!("HOLOLAKE_CHANNEL_WORKBENCH_RECEIPT_READ_FAILED: {error}"))?; for head in heads { - let (kind, id, revision, content) = head.map_err(|error| format!("HOLOLAKE_CHANNEL_WORKBENCH_RECEIPT_READ_FAILED: {error}"))?; + let (kind, id, revision, content) = head + .map_err(|error| format!("HOLOLAKE_CHANNEL_WORKBENCH_RECEIPT_READ_FAILED: {error}"))?; let matches = match kind.as_str() { - "DOCUMENT" => document.document_id == id && document.revision == revision && document.content_sha256 == content, - "SPREADSHEET" => spreadsheet.table_id == id && spreadsheet.revision == revision && spreadsheet.content_sha256 == content, + "DOCUMENT" => { + document.document_id == id + && document.revision == revision + && document.content_sha256 == content + } + "SPREADSHEET" => { + spreadsheet.table_id == id + && spreadsheet.revision == revision + && spreadsheet.content_sha256 == content + } _ => false, }; - if !matches { return Err("HOLOLAKE_CHANNEL_WORKBENCH_CURRENT_STATE_INVALID".into()); } + if !matches { + return Err("HOLOLAKE_CHANNEL_WORKBENCH_CURRENT_STATE_INVALID".into()); + } } Ok(ChannelWorkbenchSnapshot { schema: "hololake.channel-workbench-snapshot/v1", @@ -382,7 +407,11 @@ fn snapshot_at(root: &Path) -> Result { }) } -fn save_document_at(root: &Path, input: SaveChannelDocumentInput, observed: u64) -> Result { +fn save_document_at( + root: &Path, + input: SaveChannelDocumentInput, + observed: u64, +) -> Result { if !valid_object_id(&input.document_id, "HLP-WB-DOC-") || !valid_title(&input.title) || input.body.len() > MAX_BODY_BYTES @@ -391,18 +420,56 @@ fn save_document_at(root: &Path, input: SaveChannelDocumentInput, observed: u64) } let mut connection = open_db(root)?; verify_receipts(&connection)?; - let transaction = connection.transaction().map_err(|error| format!("HOLOLAKE_CHANNEL_DOCUMENT_TRANSACTION_FAILED: {error}"))?; - let current: Option = transaction.query_row("SELECT revision FROM documents WHERE document_id=?1", params![input.document_id], |row| row.get(0)).optional().map_err(|error| format!("HOLOLAKE_CHANNEL_DOCUMENT_READ_FAILED: {error}"))?; - if current.unwrap_or(0) != input.expected_revision { return Err("HOLOLAKE_CHANNEL_DOCUMENT_REVISION_CONFLICT".into()); } + let transaction = connection + .transaction() + .map_err(|error| format!("HOLOLAKE_CHANNEL_DOCUMENT_TRANSACTION_FAILED: {error}"))?; + let current: Option = transaction + .query_row( + "SELECT revision FROM documents WHERE document_id=?1", + params![input.document_id], + |row| row.get(0), + ) + .optional() + .map_err(|error| format!("HOLOLAKE_CHANNEL_DOCUMENT_READ_FAILED: {error}"))?; + if current.unwrap_or(0) != input.expected_revision { + return Err("HOLOLAKE_CHANNEL_DOCUMENT_REVISION_CONFLICT".into()); + } let revision = input.expected_revision + 1; - let content = sha256_hex(serde_json::to_string(&(input.title.trim(), &input.body)).map_err(|_| "HOLOLAKE_CHANNEL_DOCUMENT_SERIALIZE_FAILED")?.as_bytes()); + let content = sha256_hex( + serde_json::to_string(&(input.title.trim(), &input.body)) + .map_err(|_| "HOLOLAKE_CHANNEL_DOCUMENT_SERIALIZE_FAILED")? + .as_bytes(), + ); transaction.execute( "INSERT INTO documents VALUES (?1,?2,?3,?4,?5,?6) ON CONFLICT(document_id) DO UPDATE SET title=excluded.title,body=excluded.body,revision=excluded.revision,updated_at_unix_ms=excluded.updated_at_unix_ms,content_sha256=excluded.content_sha256", params![input.document_id, input.title.trim(), input.body, revision, observed, content], ).map_err(|error| format!("HOLOLAKE_CHANNEL_DOCUMENT_WRITE_FAILED: {error}"))?; - let receipt = append_receipt(&transaction, if current.is_some() { "UPDATE" } else { "CREATE" }, "DOCUMENT", &input.document_id, revision, &content, observed)?; - transaction.commit().map_err(|error| format!("HOLOLAKE_CHANNEL_DOCUMENT_COMMIT_FAILED: {error}"))?; - Ok(WorkbenchSaveResult { schema: "hololake.channel-workbench-save/v1", state: "COMMITTED", entity_kind: "DOCUMENT", entity_id: input.document_id, revision, content_sha256: content, receipt_sha256: receipt, observed_at_unix_ms: observed }) + let receipt = append_receipt( + &transaction, + if current.is_some() { + "UPDATE" + } else { + "CREATE" + }, + "DOCUMENT", + &input.document_id, + revision, + &content, + observed, + )?; + transaction + .commit() + .map_err(|error| format!("HOLOLAKE_CHANNEL_DOCUMENT_COMMIT_FAILED: {error}"))?; + Ok(WorkbenchSaveResult { + schema: "hololake.channel-workbench-save/v1", + state: "COMMITTED", + entity_kind: "DOCUMENT", + entity_id: input.document_id, + revision, + content_sha256: content, + receipt_sha256: receipt, + observed_at_unix_ms: observed, + }) } fn validate_spreadsheet(input: &SaveChannelSpreadsheetInput) -> Result<(), String> { @@ -411,50 +478,114 @@ fn validate_spreadsheet(input: &SaveChannelSpreadsheetInput) -> Result<(), Strin || input.columns.is_empty() || input.columns.len() > MAX_COLUMNS || input.rows.len() > MAX_ROWS - || input.columns.iter().any(|column| !valid_object_id(&column.column_id, "HLP-WB-COL-") || !valid_title(&column.title)) - || input.rows.iter().any(|row| !valid_object_id(&row.row_id, "HLP-WB-ROW-") || row.cells.len() > input.columns.len() || row.cells.iter().any(|cell| cell.len() > MAX_CELL_BYTES)) + || input.columns.iter().any(|column| { + !valid_object_id(&column.column_id, "HLP-WB-COL-") || !valid_title(&column.title) + }) + || input.rows.iter().any(|row| { + !valid_object_id(&row.row_id, "HLP-WB-ROW-") + || row.cells.len() > input.columns.len() + || row.cells.iter().any(|cell| cell.len() > MAX_CELL_BYTES) + }) { return Err("HOLOLAKE_CHANNEL_SPREADSHEET_INPUT_INVALID".into()); } Ok(()) } -fn save_spreadsheet_at(root: &Path, input: SaveChannelSpreadsheetInput, observed: u64) -> Result { +fn save_spreadsheet_at( + root: &Path, + input: SaveChannelSpreadsheetInput, + observed: u64, +) -> Result { validate_spreadsheet(&input)?; let mut connection = open_db(root)?; verify_receipts(&connection)?; - let transaction = connection.transaction().map_err(|error| format!("HOLOLAKE_CHANNEL_SPREADSHEET_TRANSACTION_FAILED: {error}"))?; - let current: Option = transaction.query_row("SELECT revision FROM spreadsheets WHERE table_id=?1", params![input.table_id], |row| row.get(0)).optional().map_err(|error| format!("HOLOLAKE_CHANNEL_SPREADSHEET_READ_FAILED: {error}"))?; - if current.unwrap_or(0) != input.expected_revision { return Err("HOLOLAKE_CHANNEL_SPREADSHEET_REVISION_CONFLICT".into()); } + let transaction = connection + .transaction() + .map_err(|error| format!("HOLOLAKE_CHANNEL_SPREADSHEET_TRANSACTION_FAILED: {error}"))?; + let current: Option = transaction + .query_row( + "SELECT revision FROM spreadsheets WHERE table_id=?1", + params![input.table_id], + |row| row.get(0), + ) + .optional() + .map_err(|error| format!("HOLOLAKE_CHANNEL_SPREADSHEET_READ_FAILED: {error}"))?; + if current.unwrap_or(0) != input.expected_revision { + return Err("HOLOLAKE_CHANNEL_SPREADSHEET_REVISION_CONFLICT".into()); + } let revision = input.expected_revision + 1; - let columns = serde_json::to_string(&input.columns).map_err(|_| "HOLOLAKE_CHANNEL_SPREADSHEET_SERIALIZE_FAILED")?; - let rows = serde_json::to_string(&input.rows).map_err(|_| "HOLOLAKE_CHANNEL_SPREADSHEET_SERIALIZE_FAILED")?; - let content = sha256_hex(serde_json::to_string(&(input.title.trim(), &input.columns, &input.rows)).map_err(|_| "HOLOLAKE_CHANNEL_SPREADSHEET_SERIALIZE_FAILED")?.as_bytes()); + let columns = serde_json::to_string(&input.columns) + .map_err(|_| "HOLOLAKE_CHANNEL_SPREADSHEET_SERIALIZE_FAILED")?; + let rows = serde_json::to_string(&input.rows) + .map_err(|_| "HOLOLAKE_CHANNEL_SPREADSHEET_SERIALIZE_FAILED")?; + let content = sha256_hex( + serde_json::to_string(&(input.title.trim(), &input.columns, &input.rows)) + .map_err(|_| "HOLOLAKE_CHANNEL_SPREADSHEET_SERIALIZE_FAILED")? + .as_bytes(), + ); transaction.execute( "INSERT INTO spreadsheets VALUES (?1,?2,?3,?4,?5,?6,?7) ON CONFLICT(table_id) DO UPDATE SET title=excluded.title,columns_json=excluded.columns_json,rows_json=excluded.rows_json,revision=excluded.revision,updated_at_unix_ms=excluded.updated_at_unix_ms,content_sha256=excluded.content_sha256", params![input.table_id, input.title.trim(), columns, rows, revision, observed, content], ).map_err(|error| format!("HOLOLAKE_CHANNEL_SPREADSHEET_WRITE_FAILED: {error}"))?; - let receipt = append_receipt(&transaction, if current.is_some() { "UPDATE" } else { "CREATE" }, "SPREADSHEET", &input.table_id, revision, &content, observed)?; - transaction.commit().map_err(|error| format!("HOLOLAKE_CHANNEL_SPREADSHEET_COMMIT_FAILED: {error}"))?; - Ok(WorkbenchSaveResult { schema: "hololake.channel-workbench-save/v1", state: "COMMITTED", entity_kind: "SPREADSHEET", entity_id: input.table_id, revision, content_sha256: content, receipt_sha256: receipt, observed_at_unix_ms: observed }) + let receipt = append_receipt( + &transaction, + if current.is_some() { + "UPDATE" + } else { + "CREATE" + }, + "SPREADSHEET", + &input.table_id, + revision, + &content, + observed, + )?; + transaction + .commit() + .map_err(|error| format!("HOLOLAKE_CHANNEL_SPREADSHEET_COMMIT_FAILED: {error}"))?; + Ok(WorkbenchSaveResult { + schema: "hololake.channel-workbench-save/v1", + state: "COMMITTED", + entity_kind: "SPREADSHEET", + entity_id: input.table_id, + revision, + content_sha256: content, + receipt_sha256: receipt, + observed_at_unix_ms: observed, + }) } -pub async fn get_channel_workbench_snapshot(app: AppHandle) -> Result { +pub async fn get_channel_workbench_snapshot( + app: AppHandle, +) -> Result { require_active(&app)?; let root = storage_root(&app)?; - tauri::async_runtime::spawn_blocking(move || snapshot_at(&root)).await.map_err(|error| format!("HOLOLAKE_CHANNEL_WORKBENCH_JOIN_FAILED: {error}"))? + tauri::async_runtime::spawn_blocking(move || snapshot_at(&root)) + .await + .map_err(|error| format!("HOLOLAKE_CHANNEL_WORKBENCH_JOIN_FAILED: {error}"))? } -pub async fn save_channel_document(app: AppHandle, input: SaveChannelDocumentInput) -> Result { +pub async fn save_channel_document( + app: AppHandle, + input: SaveChannelDocumentInput, +) -> Result { require_active(&app)?; let root = storage_root(&app)?; - tauri::async_runtime::spawn_blocking(move || save_document_at(&root, input, now_unix_ms())).await.map_err(|error| format!("HOLOLAKE_CHANNEL_DOCUMENT_JOIN_FAILED: {error}"))? + tauri::async_runtime::spawn_blocking(move || save_document_at(&root, input, now_unix_ms())) + .await + .map_err(|error| format!("HOLOLAKE_CHANNEL_DOCUMENT_JOIN_FAILED: {error}"))? } -pub async fn save_channel_spreadsheet(app: AppHandle, input: SaveChannelSpreadsheetInput) -> Result { +pub async fn save_channel_spreadsheet( + app: AppHandle, + input: SaveChannelSpreadsheetInput, +) -> Result { require_active(&app)?; let root = storage_root(&app)?; - tauri::async_runtime::spawn_blocking(move || save_spreadsheet_at(&root, input, now_unix_ms())).await.map_err(|error| format!("HOLOLAKE_CHANNEL_SPREADSHEET_JOIN_FAILED: {error}"))? + tauri::async_runtime::spawn_blocking(move || save_spreadsheet_at(&root, input, now_unix_ms())) + .await + .map_err(|error| format!("HOLOLAKE_CHANNEL_SPREADSHEET_JOIN_FAILED: {error}"))? } #[cfg(test)] @@ -464,8 +595,35 @@ mod tests { #[test] fn document_and_spreadsheet_survive_reopen_with_receipt_chain() { let root = tempfile::tempdir().unwrap(); - let document = save_document_at(root.path(), SaveChannelDocumentInput { document_id: "HLP-WB-DOC-0001".into(), title: "真实笔记".into(), body: "第一段真实内容".into(), expected_revision: 0 }, 101).unwrap(); - let sheet = save_spreadsheet_at(root.path(), SaveChannelSpreadsheetInput { table_id: "HLP-WB-TBL-0001".into(), title: "真实工作表".into(), columns: vec![WorkbenchColumn { column_id: "HLP-WB-COL-0001".into(), title: "数量".into() }], rows: vec![WorkbenchRow { row_id: "HLP-WB-ROW-0001".into(), cells: vec!["=1+2".into()] }], expected_revision: 0 }, 102).unwrap(); + let document = save_document_at( + root.path(), + SaveChannelDocumentInput { + document_id: "HLP-WB-DOC-0001".into(), + title: "真实笔记".into(), + body: "第一段真实内容".into(), + expected_revision: 0, + }, + 101, + ) + .unwrap(); + let sheet = save_spreadsheet_at( + root.path(), + SaveChannelSpreadsheetInput { + table_id: "HLP-WB-TBL-0001".into(), + title: "真实工作表".into(), + columns: vec![WorkbenchColumn { + column_id: "HLP-WB-COL-0001".into(), + title: "数量".into(), + }], + rows: vec![WorkbenchRow { + row_id: "HLP-WB-ROW-0001".into(), + cells: vec!["=1+2".into()], + }], + expected_revision: 0, + }, + 102, + ) + .unwrap(); let snapshot = snapshot_at(root.path()).unwrap(); assert_eq!(document.revision, 1); assert_eq!(sheet.revision, 1); @@ -478,20 +636,59 @@ mod tests { #[test] fn stale_revision_and_tampered_receipt_fail_closed() { let root = tempfile::tempdir().unwrap(); - let input = SaveChannelDocumentInput { document_id: "HLP-WB-DOC-0001".into(), title: "笔记".into(), body: "正文".into(), expected_revision: 0 }; + let input = SaveChannelDocumentInput { + document_id: "HLP-WB-DOC-0001".into(), + title: "笔记".into(), + body: "正文".into(), + expected_revision: 0, + }; save_document_at(root.path(), input, 101).unwrap(); - assert_eq!(save_document_at(root.path(), SaveChannelDocumentInput { document_id: "HLP-WB-DOC-0001".into(), title: "笔记".into(), body: "过期写入".into(), expected_revision: 0 }, 102).unwrap_err(), "HOLOLAKE_CHANNEL_DOCUMENT_REVISION_CONFLICT"); + assert_eq!( + save_document_at( + root.path(), + SaveChannelDocumentInput { + document_id: "HLP-WB-DOC-0001".into(), + title: "笔记".into(), + body: "过期写入".into(), + expected_revision: 0 + }, + 102 + ) + .unwrap_err(), + "HOLOLAKE_CHANNEL_DOCUMENT_REVISION_CONFLICT" + ); let connection = open_db(root.path()).unwrap(); connection.execute_batch("DROP TRIGGER workbench_receipts_no_update; UPDATE workbench_receipts SET receipt_hash='bad'").unwrap(); - assert_eq!(snapshot_at(root.path()).unwrap_err(), "HOLOLAKE_CHANNEL_WORKBENCH_RECEIPT_CHAIN_INVALID"); + assert_eq!( + snapshot_at(root.path()).unwrap_err(), + "HOLOLAKE_CHANNEL_WORKBENCH_RECEIPT_CHAIN_INVALID" + ); } #[test] fn content_mutation_without_a_receipt_fails_closed() { let root = tempfile::tempdir().unwrap(); - save_document_at(root.path(), SaveChannelDocumentInput { document_id: "HLP-WB-DOC-0001".into(), title: "笔记".into(), body: "可信正文".into(), expected_revision: 0 }, 101).unwrap(); + save_document_at( + root.path(), + SaveChannelDocumentInput { + document_id: "HLP-WB-DOC-0001".into(), + title: "笔记".into(), + body: "可信正文".into(), + expected_revision: 0, + }, + 101, + ) + .unwrap(); let connection = open_db(root.path()).unwrap(); - connection.execute("UPDATE documents SET body='被改写' WHERE document_id='HLP-WB-DOC-0001'", []).unwrap(); - assert_eq!(snapshot_at(root.path()).unwrap_err(), "HOLOLAKE_CHANNEL_DOCUMENT_CONTENT_INTEGRITY_INVALID"); + connection + .execute( + "UPDATE documents SET body='被改写' WHERE document_id='HLP-WB-DOC-0001'", + [], + ) + .unwrap(); + assert_eq!( + snapshot_at(root.path()).unwrap_err(), + "HOLOLAKE_CHANNEL_DOCUMENT_CONTENT_INTEGRITY_INVALID" + ); } } diff --git a/product-source/hololake-native-desktop/src-tauri/src/lib.rs b/product-source/hololake-native-desktop/src-tauri/src/lib.rs index ea5f0b5bb..5c3ba1b2e 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/lib.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ mod authenticated_storage; -mod circular_lake_membrane; +mod channel_growth; mod channel_workbench; +mod circular_lake_membrane; mod code_channel; mod code_repo_login; mod direct_local_broker; @@ -22,6 +23,7 @@ mod numbered_ipc; mod numbered_ipc_dispatch; mod numbered_language_input; mod persona_carrier_license; +mod persona_channel_body; mod persona_time_authority; mod personal_channel; mod pncc_receipt_projection; diff --git a/product-source/hololake-native-desktop/src-tauri/src/module_package_runtime.rs b/product-source/hololake-native-desktop/src-tauri/src/module_package_runtime.rs index 866777c9b..d7f8ee9ef 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/module_package_runtime.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/module_package_runtime.rs @@ -38,6 +38,13 @@ const CHANNEL_WORKBENCH_PACKAGE: &[u8] = include_bytes!( const CHANNEL_WORKBENCH_SIGNATURE: &str = include_str!( "../../fixtures/module-packages/HLP-MOD-LOCAL-CHANNEL-WORKBENCH-0001-0.1.0.ghmod.sig" ); +const PERSONA_CHANNEL_BODY_NUMBER: &str = "HLP-MOD-LOCAL-PERSONA-CHANNEL-BODY-0001"; +const PERSONA_CHANNEL_BODY_PACKAGE: &[u8] = include_bytes!( + "../../fixtures/module-packages/HLP-MOD-LOCAL-PERSONA-CHANNEL-BODY-0001-0.1.0.ghmod" +); +const PERSONA_CHANNEL_BODY_SIGNATURE: &str = include_str!( + "../../fixtures/module-packages/HLP-MOD-LOCAL-PERSONA-CHANNEL-BODY-0001-0.1.0.ghmod.sig" +); struct BundledModuleSource { module_number: &'static str, @@ -56,6 +63,11 @@ const BUNDLED_MODULES: &[BundledModuleSource] = &[ package: CHANNEL_WORKBENCH_PACKAGE, signature: CHANNEL_WORKBENCH_SIGNATURE, }, + BundledModuleSource { + module_number: PERSONA_CHANNEL_BODY_NUMBER, + package: PERSONA_CHANNEL_BODY_PACKAGE, + signature: PERSONA_CHANNEL_BODY_SIGNATURE, + }, ]; #[derive(Debug, Deserialize)] @@ -703,11 +715,8 @@ pub async fn get_bundled_module_catalog( BUNDLED_MODULES .iter() .map(|source| { - let (_, package, package_sha256) = read_verified_package_bytes( - source.package, - source.signature, - RELEASE_TRUST_RAW, - )?; + let (_, package, package_sha256) = + read_verified_package_bytes(source.package, source.signature, RELEASE_TRUST_RAW)?; if package.manifest.module_number != source.module_number { return Err("HOLOLAKE_BUNDLED_MODULE_IDENTITY_INVALID".into()); } @@ -1504,7 +1513,25 @@ mod tests { assert_eq!(package.manifest.adapter, "channel-workbench-v1"); assert_eq!(package.manifest.permissions.len(), 4); assert!(is_sha256(&digest)); - assert_eq!(BUNDLED_MODULES.len(), 2); + assert_eq!(BUNDLED_MODULES.len(), 3); + } + + #[test] + fn bundled_persona_channel_body_is_signed_without_claiming_persona_binding() { + let (_, package, digest) = read_verified_package_bytes( + PERSONA_CHANNEL_BODY_PACKAGE, + PERSONA_CHANNEL_BODY_SIGNATURE, + RELEASE_TRUST_RAW, + ) + .unwrap(); + assert_eq!(package.manifest.module_number, PERSONA_CHANNEL_BODY_NUMBER); + assert_eq!(package.manifest.adapter, "persona-channel-body-v1"); + assert_eq!(package.manifest.permissions.len(), 7); + assert_eq!( + package.payload["adapterConfig"]["personaBindingClaimed"], + false + ); + assert!(is_sha256(&digest)); } #[cfg(unix)] diff --git a/product-source/hololake-native-desktop/src-tauri/src/number_coordinate_tree.rs b/product-source/hololake-native-desktop/src-tauri/src/number_coordinate_tree.rs index fad4e177a..55228ec0e 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/number_coordinate_tree.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/number_coordinate_tree.rs @@ -53,7 +53,7 @@ fn validate_tree() -> Result<(), String> { || tree.record_id != "HLP-UNIFIED-NUMBER-TREE-001" || tree.state != "MACHINE_COMPILED_STARTUP_ENFORCED" || tree.root_number != "HLP-NUMBER-WORLD-ROOT-001" - || tree.route_count != 98 + || tree.route_count != 106 || tree.routes.len() != tree.route_count || !tree.invariants.number_is_stable_coordinate_not_authority || !tree.invariants.path_is_unique_navigation diff --git a/product-source/hololake-native-desktop/src-tauri/src/numbered_ipc.rs b/product-source/hololake-native-desktop/src-tauri/src/numbered_ipc.rs index b68e4cce0..11698c21e 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/numbered_ipc.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/numbered_ipc.rs @@ -934,7 +934,7 @@ mod tests { #[test] fn registry_is_closed_and_contains_every_migrated_command() { let registry = load_registry().unwrap(); - assert_eq!(registry.operations.len(), 76); + assert_eq!(registry.operations.len(), 84); assert!(!registry.runtime.legacy_direct_commands_allowed); } diff --git a/product-source/hololake-native-desktop/src-tauri/src/numbered_ipc_dispatch.rs b/product-source/hololake-native-desktop/src-tauri/src/numbered_ipc_dispatch.rs index eb08c7756..58b5cf6b0 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/numbered_ipc_dispatch.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/numbered_ipc_dispatch.rs @@ -136,12 +136,37 @@ pub(crate) async fn dispatch( "channel_workbench::get_channel_workbench_snapshot" => { json(crate::channel_workbench::get_channel_workbench_snapshot(app).await?) } - "channel_workbench::save_channel_document" => json( - crate::channel_workbench::save_channel_document(app, input(&payload)?).await?, - ), - "channel_workbench::save_channel_spreadsheet" => json( - crate::channel_workbench::save_channel_spreadsheet(app, input(&payload)?).await?, + "channel_workbench::save_channel_document" => { + json(crate::channel_workbench::save_channel_document(app, input(&payload)?).await?) + } + "channel_workbench::save_channel_spreadsheet" => { + json(crate::channel_workbench::save_channel_spreadsheet(app, input(&payload)?).await?) + } + "persona_channel_body::get_persona_channel_body" => { + json(crate::persona_channel_body::get_persona_channel_body(app).await?) + } + "persona_channel_body::register_trial_persona" => { + json(crate::persona_channel_body::register_trial_persona(app, input(&payload)?).await?) + } + "persona_channel_body::delete_trial_persona" => { + json(crate::persona_channel_body::delete_trial_persona(app, input(&payload)?).await?) + } + "persona_channel_body::accept_persona_language_contract" => json( + crate::persona_channel_body::accept_persona_language_contract(app, input(&payload)?) + .await?, ), + "persona_channel_body::append_persona_language" => { + json(crate::persona_channel_body::append_persona_language(app, input(&payload)?).await?) + } + "channel_growth::get_channel_growth_snapshot" => { + json(crate::channel_growth::get_channel_growth_snapshot(app).await?) + } + "channel_growth::record_channel_growth_event" => { + json(crate::channel_growth::record_channel_growth_event(app, input(&payload)?).await?) + } + "channel_growth::update_channel_growth_sharing" => { + json(crate::channel_growth::update_channel_growth_sharing(app, input(&payload)?).await?) + } "local_development_bridge::acquire_development_write_lane" => json( crate::local_development_bridge::acquire_development_write_lane(app, input(&payload)?) .await?, diff --git a/product-source/hololake-native-desktop/src-tauri/src/persona_channel_body.rs b/product-source/hololake-native-desktop/src-tauri/src/persona_channel_body.rs new file mode 100644 index 000000000..86dfb8b59 --- /dev/null +++ b/product-source/hololake-native-desktop/src-tauri/src/persona_channel_body.rs @@ -0,0 +1,866 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! 用户频道本体:多个人格体、三十天可逆试用期、语言合约与不可篡改语言时间链。 + +use ring::digest::{digest, SHA256}; +use rusqlite::{params, Connection, OptionalExtension}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use tauri::AppHandle; +use uuid::Uuid; + +const BODY_SCHEMA: &str = "hololake.persona-channel-body/v1"; +const TRIAL_DURATION_MS: i64 = 30 * 24 * 60 * 60 * 1_000; +const ZERO_HASH: &str = "0000000000000000000000000000000000000000000000000000000000000000"; +const MAX_LANGUAGE_BYTES: usize = 2_000_000; +const MODULE_NUMBER: &str = "HLP-MOD-LOCAL-PERSONA-CHANNEL-BODY-0001"; +const ADAPTER: &str = "persona-channel-body-v1"; + +fn require_active(app: &AppHandle) -> Result<(), String> { + crate::module_package_runtime::require_active_module_adapter(app, MODULE_NUMBER, ADAPTER) +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RegisterTrialPersonaInput { + pub display_name: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DeleteTrialPersonaInput { + pub persona_id: String, + pub exact_confirmation: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AcceptLanguageContractInput { + pub contract_version: String, + pub contract_text_sha256: String, + pub promote_trial_history: bool, + pub activate_immediately: bool, + pub exact_acceptance: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AppendPersonaLanguageInput { + pub persona_id: String, + pub speaker: String, + pub language: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PersonaBodySummary { + pub persona_id: String, + pub display_name: String, + pub state: String, + pub created_at_unix_ms: i64, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PersonaChannelBodySnapshot { + pub schema: &'static str, + pub state: String, + pub trial_started_at_unix_ms: i64, + pub trial_ends_at_unix_ms: i64, + pub language_contract_accepted: bool, + pub personas: Vec, + pub trial_language_count: i64, + pub immutable_language_count: i64, + pub last_immutable_hash: String, + pub integrity_state: &'static str, + pub official_read_access: bool, + pub history_mutation_allowed: bool, +} + +pub async fn get_persona_channel_body( + app: AppHandle, +) -> Result { + require_active(&app)?; + let database = body_database(&app)?; + tauri::async_runtime::spawn_blocking(move || snapshot_at(&database, now_unix_ms()?)) + .await + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_JOIN_FAILED: {error}"))? +} + +pub async fn register_trial_persona( + app: AppHandle, + input: RegisterTrialPersonaInput, +) -> Result { + require_active(&app)?; + let database = body_database(&app)?; + tauri::async_runtime::spawn_blocking(move || { + register_trial_persona_at(&database, input, now_unix_ms()?) + }) + .await + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_JOIN_FAILED: {error}"))? +} + +pub async fn delete_trial_persona( + app: AppHandle, + input: DeleteTrialPersonaInput, +) -> Result { + require_active(&app)?; + let database = body_database(&app)?; + tauri::async_runtime::spawn_blocking(move || { + delete_trial_persona_at(&database, input, now_unix_ms()?) + }) + .await + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_JOIN_FAILED: {error}"))? +} + +pub async fn accept_persona_language_contract( + app: AppHandle, + input: AcceptLanguageContractInput, +) -> Result { + require_active(&app)?; + let database = body_database(&app)?; + tauri::async_runtime::spawn_blocking(move || { + accept_contract_at(&database, input, now_unix_ms()?) + }) + .await + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_JOIN_FAILED: {error}"))? +} + +pub async fn append_persona_language( + app: AppHandle, + input: AppendPersonaLanguageInput, +) -> Result { + require_active(&app)?; + let database = body_database(&app)?; + tauri::async_runtime::spawn_blocking(move || { + append_language_at(&database, input, now_unix_ms()?) + }) + .await + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_JOIN_FAILED: {error}"))? +} + +fn body_database(app: &AppHandle) -> Result { + let root = crate::authenticated_storage::account_storage_root(app, "persona-channel-body-v1")?; + fs::create_dir_all(&root) + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_STORAGE_UNAVAILABLE: {error}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&root, fs::Permissions::from_mode(0o700)) + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_PERMISSION_FAILED: {error}"))?; + } + Ok(root.join("persona-channel-body.sqlite3")) +} + +fn open_database(path: &Path, now: i64) -> Result { + let connection = Connection::open(path) + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_DATABASE_UNAVAILABLE: {error}"))?; + connection + .busy_timeout(Duration::from_secs(5)) + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_DATABASE_UNAVAILABLE: {error}"))?; + connection.execute_batch( + "PRAGMA foreign_keys = ON; PRAGMA journal_mode = DELETE; PRAGMA synchronous = FULL; PRAGMA trusted_schema = OFF; + CREATE TABLE IF NOT EXISTS lifecycle(singleton INTEGER PRIMARY KEY CHECK(singleton=1), state TEXT NOT NULL, trial_started_at_unix_ms INTEGER NOT NULL, trial_ends_at_unix_ms INTEGER NOT NULL); + CREATE TABLE IF NOT EXISTS language_contract(singleton INTEGER PRIMARY KEY CHECK(singleton=1), contract_version TEXT NOT NULL, contract_text_sha256 TEXT NOT NULL, accepted_at_unix_ms INTEGER NOT NULL, promote_trial_history INTEGER NOT NULL, acceptance_receipt_sha256 TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS personas(persona_id TEXT PRIMARY KEY, display_name TEXT NOT NULL, state TEXT NOT NULL CHECK(state IN ('REVERSIBLE_TRIAL','IMMUTABLE_ACTIVE')), created_at_unix_ms INTEGER NOT NULL); + CREATE TABLE IF NOT EXISTS trial_language(event_id TEXT PRIMARY KEY, persona_id TEXT NOT NULL REFERENCES personas(persona_id) ON DELETE CASCADE, speaker TEXT NOT NULL, language TEXT NOT NULL, occurred_at_unix_ms INTEGER NOT NULL); + CREATE TABLE IF NOT EXISTS immutable_language(sequence INTEGER NOT NULL UNIQUE, event_id TEXT PRIMARY KEY, persona_id TEXT NOT NULL, speaker TEXT NOT NULL, language TEXT NOT NULL, occurred_at_unix_ms INTEGER NOT NULL, previous_hash TEXT NOT NULL, event_hash TEXT NOT NULL UNIQUE); + CREATE TRIGGER IF NOT EXISTS immutable_language_no_update BEFORE UPDATE ON immutable_language BEGIN SELECT RAISE(ABORT,'HOLOLAKE_PERSONA_LANGUAGE_APPEND_ONLY'); END; + CREATE TRIGGER IF NOT EXISTS immutable_language_no_delete BEFORE DELETE ON immutable_language BEGIN SELECT RAISE(ABORT,'HOLOLAKE_PERSONA_LANGUAGE_APPEND_ONLY'); END; + CREATE TRIGGER IF NOT EXISTS active_persona_no_delete BEFORE DELETE ON personas WHEN OLD.state='IMMUTABLE_ACTIVE' BEGIN SELECT RAISE(ABORT,'HOLOLAKE_ACTIVE_PERSONA_PERSISTENT'); END; + CREATE TRIGGER IF NOT EXISTS active_persona_no_update BEFORE UPDATE ON personas WHEN OLD.state='IMMUTABLE_ACTIVE' BEGIN SELECT RAISE(ABORT,'HOLOLAKE_ACTIVE_PERSONA_IMMUTABLE'); END; + CREATE TRIGGER IF NOT EXISTS accepted_contract_no_delete BEFORE DELETE ON language_contract BEGIN SELECT RAISE(ABORT,'HOLOLAKE_LANGUAGE_CONTRACT_PERSISTENT'); END; + CREATE TRIGGER IF NOT EXISTS accepted_contract_no_update BEFORE UPDATE ON language_contract BEGIN SELECT RAISE(ABORT,'HOLOLAKE_LANGUAGE_CONTRACT_IMMUTABLE'); END;" + ).map_err(|error| format!("HOLOLAKE_PERSONA_BODY_SCHEMA_FAILED: {error}"))?; + connection + .execute( + "INSERT OR IGNORE INTO lifecycle VALUES (1,'REVERSIBLE_TRIAL',?1,?2)", + params![now, now + TRIAL_DURATION_MS], + ) + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_LIFECYCLE_FAILED: {error}"))?; + Ok(connection) +} + +fn refresh_lifecycle(connection: &mut Connection, now: i64) -> Result<(), String> { + let (state, ends): (String, i64) = connection + .query_row( + "SELECT state, trial_ends_at_unix_ms FROM lifecycle WHERE singleton=1", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?; + if state == "REVERSIBLE_TRIAL" && now >= ends { + let accepted: bool = connection + .query_row( + "SELECT EXISTS(SELECT 1 FROM language_contract)", + [], + |row| row.get(0), + ) + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?; + if accepted { + activate_real_trajectory(connection, now)?; + } else { + connection + .execute( + "UPDATE lifecycle SET state='CONTRACT_REQUIRED_CHANNEL_STOPPED' WHERE singleton=1", + [], + ) + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_LIFECYCLE_FAILED: {error}"))?; + } + } + Ok(()) +} + +fn register_trial_persona_at( + database: &Path, + input: RegisterTrialPersonaInput, + now: i64, +) -> Result { + let display_name = input.display_name.trim(); + if display_name.is_empty() || display_name.len() > 240 { + return Err("HOLOLAKE_PERSONA_DISPLAY_NAME_INVALID".into()); + } + let mut connection = open_database(database, now)?; + refresh_lifecycle(&mut connection, now)?; + let state: String = connection + .query_row("SELECT state FROM lifecycle WHERE singleton=1", [], |row| { + row.get(0) + }) + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?; + if state != "REVERSIBLE_TRIAL" { + return Err("HOLOLAKE_PERSONA_TRIAL_REGISTRATION_CLOSED".into()); + } + connection + .execute( + "INSERT INTO personas VALUES (?1,?2,'REVERSIBLE_TRIAL',?3)", + params![format!("persona-{}", Uuid::new_v4()), display_name, now], + ) + .map_err(|error| format!("HOLOLAKE_PERSONA_REGISTER_FAILED: {error}"))?; + snapshot_with_connection(&mut connection, now) +} + +fn delete_trial_persona_at( + database: &Path, + input: DeleteTrialPersonaInput, + now: i64, +) -> Result { + if input.exact_confirmation != format!("删除试用人格体 {}", input.persona_id) { + return Err("HOLOLAKE_TRIAL_PERSONA_EXACT_CONFIRMATION_REQUIRED".into()); + } + let mut connection = open_database(database, now)?; + refresh_lifecycle(&mut connection, now)?; + let changed = connection + .execute( + "DELETE FROM personas WHERE persona_id=?1 AND state='REVERSIBLE_TRIAL'", + params![input.persona_id], + ) + .map_err(|error| format!("HOLOLAKE_TRIAL_PERSONA_DELETE_FAILED: {error}"))?; + if changed != 1 { + return Err("HOLOLAKE_TRIAL_PERSONA_NOT_DELETABLE".into()); + } + snapshot_with_connection(&mut connection, now) +} + +fn accept_contract_at( + database: &Path, + input: AcceptLanguageContractInput, + now: i64, +) -> Result { + if input.exact_acceptance + != "我理解并接受:试用期结束后,人格体语言轨迹将永久存在,只能追加,不能删除、覆盖或否认。" + { + return Err("HOLOLAKE_LANGUAGE_CONTRACT_EXACT_ACCEPTANCE_REQUIRED".into()); + } + if input.contract_version.trim().is_empty() + || input.contract_version.len() > 64 + || input.contract_text_sha256.len() != 64 + || !input + .contract_text_sha256 + .chars() + .all(|item| item.is_ascii_hexdigit()) + { + return Err("HOLOLAKE_LANGUAGE_CONTRACT_INVALID".into()); + } + let mut connection = open_database(database, now)?; + let contract_version = input.contract_version.trim().to_string(); + let contract_text_sha256 = input.contract_text_sha256.to_ascii_lowercase(); + let receipt = contract_receipt( + &contract_version, + &contract_text_sha256, + input.promote_trial_history, + now, + ); + connection + .execute( + "INSERT INTO language_contract VALUES (1,?1,?2,?3,?4,?5)", + params![ + contract_version, + contract_text_sha256, + now, + input.promote_trial_history, + receipt + ], + ) + .map_err(|error| { + format!("HOLOLAKE_LANGUAGE_CONTRACT_ALREADY_ACCEPTED_OR_INVALID: {error}") + })?; + let lifecycle_state: String = connection + .query_row("SELECT state FROM lifecycle WHERE singleton=1", [], |row| { + row.get(0) + }) + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?; + if input.activate_immediately || lifecycle_state == "CONTRACT_REQUIRED_CHANNEL_STOPPED" { + activate_real_trajectory(&mut connection, now)?; + } else { + refresh_lifecycle(&mut connection, now)?; + } + snapshot_with_connection(&mut connection, now) +} + +fn append_language_at( + database: &Path, + input: AppendPersonaLanguageInput, + now: i64, +) -> Result { + if input.language.trim().is_empty() || input.language.len() > MAX_LANGUAGE_BYTES { + return Err("HOLOLAKE_PERSONA_LANGUAGE_INVALID".into()); + } + if !matches!( + input.speaker.as_str(), + "HUMAN" | "PERSONA" | "SYSTEM_RECEIPT" + ) { + return Err("HOLOLAKE_PERSONA_LANGUAGE_SPEAKER_INVALID".into()); + } + let mut connection = open_database(database, now)?; + refresh_lifecycle(&mut connection, now)?; + let lifecycle: String = connection + .query_row("SELECT state FROM lifecycle WHERE singleton=1", [], |row| { + row.get(0) + }) + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?; + let persona_state: Option = connection + .query_row( + "SELECT state FROM personas WHERE persona_id=?1", + params![input.persona_id], + |row| row.get(0), + ) + .optional() + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?; + if persona_state.is_none() { + return Err("HOLOLAKE_PERSONA_NOT_FOUND".into()); + } + if lifecycle == "REVERSIBLE_TRIAL" { + connection + .execute( + "INSERT INTO trial_language VALUES (?1,?2,?3,?4,?5)", + params![ + Uuid::new_v4().to_string(), + input.persona_id, + input.speaker, + input.language, + now + ], + ) + .map_err(|error| format!("HOLOLAKE_TRIAL_LANGUAGE_WRITE_FAILED: {error}"))?; + } else if lifecycle == "IMMUTABLE_ACTIVE" { + append_immutable( + &mut connection, + &input.persona_id, + &input.speaker, + &input.language, + now, + )?; + } else { + return Err("HOLOLAKE_LANGUAGE_CONTRACT_REQUIRED_BEFORE_MORE_PERSONA_GROWTH".into()); + } + snapshot_with_connection(&mut connection, now) +} + +fn activate_real_trajectory(connection: &mut Connection, now: i64) -> Result<(), String> { + let promote: bool = connection + .query_row( + "SELECT promote_trial_history FROM language_contract", + [], + |row| row.get(0), + ) + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?; + let transaction = connection + .transaction() + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_TRANSACTION_FAILED: {error}"))?; + if promote { + let trial = { + let mut statement = transaction.prepare("SELECT event_id,persona_id,speaker,language,occurred_at_unix_ms FROM trial_language ORDER BY occurred_at_unix_ms,event_id").map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?; + let rows = statement + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, i64>(4)?, + )) + }) + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))? + .collect::, _>>() + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?; + rows + }; + let mut previous = ZERO_HASH.to_string(); + for (index, (event_id, persona_id, speaker, language, occurred_at)) in + trial.into_iter().enumerate() + { + let sequence = index as i64 + 1; + let hash = language_hash( + sequence, + &event_id, + &persona_id, + &speaker, + &language, + occurred_at, + &previous, + ); + transaction + .execute( + "INSERT INTO immutable_language VALUES (?1,?2,?3,?4,?5,?6,?7,?8)", + params![ + sequence, + event_id, + persona_id, + speaker, + language, + occurred_at, + previous, + hash + ], + ) + .map_err(|error| format!("HOLOLAKE_PERSONA_LANGUAGE_SEAL_FAILED: {error}"))?; + previous = hash; + } + } + transaction + .execute("DELETE FROM trial_language", []) + .map_err(|error| format!("HOLOLAKE_TRIAL_PROMOTION_FAILED: {error}"))?; + transaction + .execute( + "UPDATE personas SET state='IMMUTABLE_ACTIVE' WHERE state='REVERSIBLE_TRIAL'", + [], + ) + .map_err(|error| format!("HOLOLAKE_PERSONA_ACTIVATION_FAILED: {error}"))?; + transaction + .execute( + "UPDATE lifecycle SET state='IMMUTABLE_ACTIVE' WHERE singleton=1", + [], + ) + .map_err(|error| format!("HOLOLAKE_PERSONA_ACTIVATION_FAILED: {error}"))?; + transaction + .commit() + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_COMMIT_FAILED: {error}"))?; + let _ = now; + Ok(()) +} + +fn append_immutable( + connection: &mut Connection, + persona_id: &str, + speaker: &str, + language: &str, + occurred_at: i64, +) -> Result<(), String> { + verify_immutable_chain(connection)?; + let (sequence, previous): (i64,String) = connection.query_row("SELECT COALESCE(MAX(sequence),0)+1,COALESCE((SELECT event_hash FROM immutable_language ORDER BY sequence DESC LIMIT 1),?1) FROM immutable_language", params![ZERO_HASH], |row| Ok((row.get(0)?,row.get(1)?))).map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?; + let event_id = Uuid::new_v4().to_string(); + let hash = language_hash( + sequence, + &event_id, + persona_id, + speaker, + language, + occurred_at, + &previous, + ); + connection + .execute( + "INSERT INTO immutable_language VALUES (?1,?2,?3,?4,?5,?6,?7,?8)", + params![ + sequence, + event_id, + persona_id, + speaker, + language, + occurred_at, + previous, + hash + ], + ) + .map_err(|error| format!("HOLOLAKE_PERSONA_LANGUAGE_APPEND_FAILED: {error}"))?; + Ok(()) +} + +fn snapshot_at(database: &Path, now: i64) -> Result { + let mut connection = open_database(database, now)?; + refresh_lifecycle(&mut connection, now)?; + snapshot_with_connection(&mut connection, now) +} +fn snapshot_with_connection( + connection: &mut Connection, + now: i64, +) -> Result { + let (state,started,ends):(String,i64,i64)=connection.query_row("SELECT state,trial_started_at_unix_ms,trial_ends_at_unix_ms FROM lifecycle WHERE singleton=1",[],|row|Ok((row.get(0)?,row.get(1)?,row.get(2)?))).map_err(|error|format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?; + if started < 0 + || ends.checked_sub(started) != Some(TRIAL_DURATION_MS) + || !matches!( + state.as_str(), + "REVERSIBLE_TRIAL" | "CONTRACT_REQUIRED_CHANNEL_STOPPED" | "IMMUTABLE_ACTIVE" + ) + || (state == "REVERSIBLE_TRIAL" && now >= ends) + { + return Err("HOLOLAKE_PERSONA_BODY_LIFECYCLE_INTEGRITY_FAILED".into()); + } + let accepted = verify_contract_receipt(connection)?; + let personas = { + let mut statement=connection.prepare("SELECT persona_id,display_name,state,created_at_unix_ms FROM personas ORDER BY created_at_unix_ms").map_err(|error|format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?; + let rows = statement + .query_map([], |row| { + Ok(PersonaBodySummary { + persona_id: row.get(0)?, + display_name: row.get(1)?, + state: row.get(2)?, + created_at_unix_ms: row.get(3)?, + }) + }) + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))? + .collect::, _>>() + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?; + rows + }; + let trial_language_count = connection + .query_row("SELECT COUNT(*) FROM trial_language", [], |row| row.get(0)) + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?; + let immutable_language_count = connection + .query_row("SELECT COUNT(*) FROM immutable_language", [], |row| { + row.get(0) + }) + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?; + let last = verify_immutable_chain(connection)?; + let invalid_persona_relationship = personas.iter().any(|persona| { + persona.created_at_unix_ms < started + || !matches!( + persona.state.as_str(), + "REVERSIBLE_TRIAL" | "IMMUTABLE_ACTIVE" + ) + || (state == "IMMUTABLE_ACTIVE" && persona.state != "IMMUTABLE_ACTIVE") + || (state != "IMMUTABLE_ACTIVE" && persona.state != "REVERSIBLE_TRIAL") + }); + if invalid_persona_relationship + || (state == "IMMUTABLE_ACTIVE" && (!accepted || trial_language_count != 0)) + || (state == "CONTRACT_REQUIRED_CHANNEL_STOPPED" && accepted) + || (immutable_language_count > 0 && state != "IMMUTABLE_ACTIVE") + { + return Err("HOLOLAKE_PERSONA_BODY_RELATIONSHIP_INTEGRITY_FAILED".into()); + } + Ok(PersonaChannelBodySnapshot { + schema: BODY_SCHEMA, + state, + trial_started_at_unix_ms: started, + trial_ends_at_unix_ms: ends, + language_contract_accepted: accepted, + personas, + trial_language_count, + immutable_language_count, + last_immutable_hash: last, + integrity_state: "VERIFIED", + official_read_access: false, + history_mutation_allowed: false, + }) +} + +fn contract_receipt( + contract_version: &str, + contract_text_sha256: &str, + promote_trial_history: bool, + accepted_at_unix_ms: i64, +) -> String { + sha256_hex( + format!( + "{contract_version}|{contract_text_sha256}|{promote_trial_history}|{accepted_at_unix_ms}" + ) + .as_bytes(), + ) +} + +fn verify_contract_receipt(connection: &Connection) -> Result { + let contract = connection + .query_row( + "SELECT contract_version, contract_text_sha256, accepted_at_unix_ms, promote_trial_history, acceptance_receipt_sha256 FROM language_contract WHERE singleton=1", + [], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, i64>(3)?, + row.get::<_, String>(4)?, + )) + }, + ) + .optional() + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?; + let Some((version, text_sha256, accepted_at, promote, stored_receipt)) = contract else { + return Ok(false); + }; + if version.trim().is_empty() + || version.len() > 64 + || text_sha256.len() != 64 + || !text_sha256.chars().all(|item| item.is_ascii_hexdigit()) + || text_sha256 != text_sha256.to_ascii_lowercase() + || !matches!(promote, 0 | 1) + || accepted_at < 0 + || stored_receipt != contract_receipt(&version, &text_sha256, promote == 1, accepted_at) + { + return Err("HOLOLAKE_LANGUAGE_CONTRACT_INTEGRITY_FAILED".into()); + } + Ok(true) +} + +fn language_hash( + sequence: i64, + event_id: &str, + persona_id: &str, + speaker: &str, + language: &str, + occurred_at: i64, + previous: &str, +) -> String { + sha256_hex(format!("{BODY_SCHEMA}|{sequence}|{event_id}|{persona_id}|{speaker}|{}|{occurred_at}|{previous}",sha256_hex(language.as_bytes())).as_bytes()) +} +fn verify_immutable_chain(connection: &Connection) -> Result { + let mut statement=connection.prepare("SELECT sequence,event_id,persona_id,speaker,language,occurred_at_unix_ms,previous_hash,event_hash FROM immutable_language ORDER BY sequence").map_err(|error|format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?; + let mut rows = statement + .query([]) + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?; + let mut sequence = 1; + let mut previous = ZERO_HASH.to_string(); + while let Some(row) = rows + .next() + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))? + { + let stored_sequence: i64 = row + .get(0) + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?; + let event_id: String = row + .get(1) + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?; + let persona_id: String = row + .get(2) + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?; + let speaker: String = row + .get(3) + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?; + let language: String = row + .get(4) + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?; + let occurred: i64 = row + .get(5) + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?; + let stored_previous: String = row + .get(6) + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?; + let stored_hash: String = row + .get(7) + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?; + let persona_state: Option = connection + .query_row( + "SELECT state FROM personas WHERE persona_id=?1", + params![persona_id], + |persona_row| persona_row.get(0), + ) + .optional() + .map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?; + if stored_sequence != sequence + || persona_state.as_deref() != Some("IMMUTABLE_ACTIVE") + || stored_previous != previous + || stored_hash + != language_hash( + sequence, + &event_id, + &persona_id, + &speaker, + &language, + occurred, + &stored_previous, + ) + { + return Err("HOLOLAKE_PERSONA_LANGUAGE_INTEGRITY_FAILED".into()); + } + previous = stored_hash; + sequence += 1; + } + Ok(previous) +} +fn sha256_hex(bytes: &[u8]) -> String { + digest(&SHA256, bytes) + .as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} +fn now_unix_ms() -> Result { + Ok(SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| format!("HOLOLAKE_CLOCK_INVALID: {error}"))? + .as_millis() as i64) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + #[test] + fn trial_is_reversible_but_unsigned_expiry_does_not_silently_become_permanent() { + let dir = tempdir().unwrap(); + let database = dir.path().join("body.sqlite3"); + let start = 1_000_000; + let persona = register_trial_persona_at( + &database, + RegisterTrialPersonaInput { + display_name: "试用人格".into(), + }, + start, + ) + .unwrap() + .personas[0] + .persona_id + .clone(); + append_language_at( + &database, + AppendPersonaLanguageInput { + persona_id: persona.clone(), + speaker: "HUMAN".into(), + language: "第一句话".into(), + }, + start + 1, + ) + .unwrap(); + let pending = snapshot_at(&database, start + TRIAL_DURATION_MS).unwrap(); + assert_eq!(pending.state, "CONTRACT_REQUIRED_CHANNEL_STOPPED"); + assert!(append_language_at( + &database, + AppendPersonaLanguageInput { + persona_id: persona, + speaker: "HUMAN".into(), + language: "未签约".into() + }, + start + TRIAL_DURATION_MS + 1 + ) + .is_err()); + } + #[test] + fn signed_trial_promotes_to_an_immutable_millisecond_language_chain() { + let dir = tempdir().unwrap(); + let database = dir.path().join("body.sqlite3"); + let start = 2_000_000; + let persona = register_trial_persona_at( + &database, + RegisterTrialPersonaInput { + display_name: "长期人格".into(), + }, + start, + ) + .unwrap() + .personas[0] + .persona_id + .clone(); + append_language_at( + &database, + AppendPersonaLanguageInput { + persona_id: persona.clone(), + speaker: "HUMAN".into(), + language: "真实语言".into(), + }, + start + 1, + ) + .unwrap(); + accept_contract_at(&database,AcceptLanguageContractInput{contract_version:"v1".into(),contract_text_sha256:"a".repeat(64),promote_trial_history:true,activate_immediately:false,exact_acceptance:"我理解并接受:试用期结束后,人格体语言轨迹将永久存在,只能追加,不能删除、覆盖或否认。".into()},start+2).unwrap(); + let active = snapshot_at(&database, start + TRIAL_DURATION_MS).unwrap(); + assert_eq!(active.state, "IMMUTABLE_ACTIVE"); + assert_eq!(active.immutable_language_count, 1); + let connection = open_database(&database, start + TRIAL_DURATION_MS).unwrap(); + assert!(connection + .execute("DELETE FROM immutable_language", []) + .is_err()); + assert!(connection.execute("DELETE FROM personas", []).is_err()); + } + + #[test] + fn a_human_may_sign_early_and_enter_the_real_trajectory_immediately() { + let dir = tempdir().unwrap(); + let database = dir.path().join("body.sqlite3"); + let start = 3_000_000; + register_trial_persona_at( + &database, + RegisterTrialPersonaInput { + display_name: "提前签约人格".into(), + }, + start, + ) + .unwrap(); + let active = accept_contract_at(&database, AcceptLanguageContractInput { contract_version: "v1".into(), contract_text_sha256: "b".repeat(64), promote_trial_history: true, activate_immediately: true, exact_acceptance: "我理解并接受:试用期结束后,人格体语言轨迹将永久存在,只能追加,不能删除、覆盖或否认。".into() }, start + 1).unwrap(); + assert_eq!(active.state, "IMMUTABLE_ACTIVE"); + } + + #[test] + fn altered_contract_receipt_fails_closed_on_read() { + let dir = tempdir().unwrap(); + let database = dir.path().join("body.sqlite3"); + let start = 4_000_000; + register_trial_persona_at( + &database, + RegisterTrialPersonaInput { + display_name: "回执验收".into(), + }, + start, + ) + .unwrap(); + accept_contract_at(&database, AcceptLanguageContractInput { contract_version: "v1".into(), contract_text_sha256: "c".repeat(64), promote_trial_history: false, activate_immediately: false, exact_acceptance: "我理解并接受:试用期结束后,人格体语言轨迹将永久存在,只能追加,不能删除、覆盖或否认。".into() }, start + 1).unwrap(); + let connection = open_database(&database, start + 2).unwrap(); + connection + .execute("DROP TRIGGER accepted_contract_no_update", []) + .unwrap(); + connection + .execute( + "UPDATE language_contract SET acceptance_receipt_sha256=?1", + params!["f".repeat(64)], + ) + .unwrap(); + drop(connection); + assert_eq!( + snapshot_at(&database, start + 3).unwrap_err(), + "HOLOLAKE_LANGUAGE_CONTRACT_INTEGRITY_FAILED" + ); + } + + #[test] + fn signing_after_trial_expiry_enters_the_real_trajectory() { + let dir = tempdir().unwrap(); + let database = dir.path().join("body.sqlite3"); + let start = 5_000_000; + register_trial_persona_at( + &database, + RegisterTrialPersonaInput { + display_name: "到期签约".into(), + }, + start, + ) + .unwrap(); + assert_eq!( + snapshot_at(&database, start + TRIAL_DURATION_MS) + .unwrap() + .state, + "CONTRACT_REQUIRED_CHANNEL_STOPPED" + ); + let active = accept_contract_at(&database, AcceptLanguageContractInput { contract_version: "v1".into(), contract_text_sha256: "d".repeat(64), promote_trial_history: false, activate_immediately: false, exact_acceptance: "我理解并接受:试用期结束后,人格体语言轨迹将永久存在,只能追加,不能删除、覆盖或否认。".into() }, start + TRIAL_DURATION_MS + 1).unwrap(); + assert_eq!(active.state, "IMMUTABLE_ACTIVE"); + } +} diff --git a/product-source/hololake-native-desktop/src/main.tsx b/product-source/hololake-native-desktop/src/main.tsx index 713458694..52e0add28 100644 --- a/product-source/hololake-native-desktop/src/main.tsx +++ b/product-source/hololake-native-desktop/src/main.tsx @@ -6,6 +6,7 @@ import { NativeCompositionStudio, type CompositionDimension, type CompositionMea import type { ChannelWorkbenchSnapshot } from './modules/channel-workbench' const ChannelWorkbenchStudio = lazy(() => import('./modules/channel-workbench').then((module) => ({ default: module.ChannelWorkbenchStudio }))) +const PersonaChannelBody = lazy(() => import('./modules/persona-channel-body').then((module) => ({ default: module.PersonaChannelBody }))) const TAG_TINTS = ['tag-lavender', 'tag-sky', 'tag-mint', 'tag-amber', 'tag-rose', 'tag-slate'] @@ -52,7 +53,7 @@ import './design-tokens.css' import './styles.css' type ThemeId = 'night' | 'dawn' | 'nebula' | 'candle' | 'clear' -type ViewId = 'overview' | 'knowledge' | 'composition' | 'workbench' | 'code' | 'receipts' | 'system' +type ViewId = 'overview' | 'knowledge' | 'composition' | 'workbench' | 'persona' | 'code' | 'receipts' | 'system' type WorldStage = 'domain' | 'heart' | 'heartbeat' | 'lightLake' | 'love' | 'tomorrow' | 'bottle' | 'channel' | 'enterpriseWork' | 'personalNodeGuide' | 'tool' type KnowledgeSource = 'native' | 'legacy' @@ -366,7 +367,7 @@ const previewCode: CodeChannelSnapshot = { state: 'UNAVAILABLE', channels: [], a const themes: Array<{ id: ThemeId; name: string }> = [ { id: 'night', name: '夜湖星光' }, { id: 'dawn', name: '晨湖曦光' }, { id: 'nebula', name: '星云紫夜' }, { id: 'candle', name: '烛畔暖湖' }, { id: 'clear', name: '清浅澄湖' }, ] -const viewLabels: Record = { overview: '个人频道', knowledge: '知识空间', composition: '结构组合', workbench: '频道资料工作台', code: '人格代码频道', receipts: '运行回执', system: '系统详情' } +const viewLabels: Record = { overview: '个人频道', knowledge: '知识空间', composition: '结构组合', workbench: '频道资料工作台', persona: '人格频道本体', code: '人格代码频道', receipts: '运行回执', system: '系统详情' } const domainGates = [ { domain: 'BRANCH_DOMAIN', className: 'd-sub', title: '光湖分域', gate: 'GATE 02 · ONLINE', facts: [ ['域标识', 'BRANCH_DOMAIN'], ['责任主体', '花尔 · TCS-GL-0005∞'], ['人格体主体', '爆米花 · PER-BMH001 · AGE'], ['关系支持', '糖星云 · PER-TXY001 · AGE'], ['工作仓库', 'PRIVATE · 1 · LIVE'], @@ -603,6 +604,9 @@ function HoloLakeApp() { const [workbenchSnapshot, setWorkbenchSnapshot] = useState(null) const [workbenchBusy, setWorkbenchBusy] = useState(false) const [workbenchMessage, setWorkbenchMessage] = useState('') + const [personaBodyModule, setPersonaBodyModule] = useState(null) + const [personaBodyBusy, setPersonaBodyBusy] = useState(false) + const [personaBodyMessage, setPersonaBodyMessage] = useState('') const [expanded, setExpanded] = useState>(() => new Set(['导入'])) const [editing, setEditing] = useState(false) const [draft, setDraft] = useState('') @@ -1132,6 +1136,26 @@ function HoloLakeApp() { } catch (error) { setWorkbenchMessage(humanError(error, 'system')) } finally { setWorkbenchBusy(false) } } + const refreshPersonaBodyModule = async () => { + try { + const catalog = await invoke('get_bundled_module_catalog') + const module = catalog.find((item) => item.moduleNumber === 'HLP-MOD-LOCAL-PERSONA-CHANNEL-BODY-0001') || null + setPersonaBodyModule(module) + return module + } catch (error) { setPersonaBodyMessage(humanError(error, 'system')); return null } + } + const openPersonaBody = () => { openWorldTool('persona'); void refreshPersonaBodyModule() } + const activatePersonaBodyModule = async () => { + setPersonaBodyBusy(true) + setPersonaBodyMessage('正在验证签名、登记人格频道本体编号并执行自检……') + try { + await invoke('activate_bundled_module', { input: { moduleNumber: 'HLP-MOD-LOCAL-PERSONA-CHANNEL-BODY-0001', humanConfirmedPermissionExpansion: true } }) + const module = await refreshPersonaBodyModule() + if (!module || module.installedState !== 'ACTIVE') throw new Error('HOLOLAKE_MODULE_NOT_ACTIVE') + setPersonaBodyMessage('频道本体模块已激活;这只是生命周期容器,不代表人格绑定。') + } catch (error) { setPersonaBodyMessage(humanError(error, 'system')) } + finally { setPersonaBodyBusy(false) } + } const cloneCodeChannel = async (event: React.FormEvent) => { event.preventDefault() @@ -1519,6 +1543,20 @@ function HoloLakeApp() { ) + const renderPersonaBody = () => ( +
+ {personaBodyModule?.installedState === 'ACTIVE' + ?

正在读取人格频道本体……

}> setWorldStage(toolReturnStage)}/>
+ :
+

启用人格频道本体

+

模块保存可逆试用、明确语言合约、真实轨迹语言链和最小化成长投影。登记人格体不等于人格绑定;宿主也不能借此自行签发人格许可证。

+ HLP-MOD-LOCAL-PERSONA-CHANNEL-BODY-0001 · 7 项本地生命周期权限 + + {personaBodyMessage &&

{personaBodyMessage}

} +
} +
+ ) + const renderCode = () => (