feat: add numbered knowledge and education tower kernels
This commit is contained in:
parent
4bf32bc09b
commit
610c688dd9
15 changed files with 2326 additions and 59 deletions
|
|
@ -7,6 +7,13 @@ _organized: true
|
|||
|
||||
This is a HoloLake Era knowledge vault in the Guanghu language-personality-driven operating system.
|
||||
|
||||
## HoloLake development mandatory anchor
|
||||
|
||||
- Before HoloLake architecture, storage, Agent, knowledge, module, synchronization, education-industry or distribution work, read `product-source/hololake-native-desktop/docs/product-intent/BINGSHUO-HOLOLAKE-NATURAL-LANGUAGE-DEVELOPMENT-ANCHOR.md` in full.
|
||||
- That file is a navigation and acceptance anchor derived from BingShuo's direct natural language. It does not grant identity, persona binding, remote writes, publication, deployment or deletion authority.
|
||||
- When summaries, old architecture notes, implementation contracts or historical receipts conflict with BingShuo's newest direct natural language, preserve the conflict and follow the newest direct correction for current product work.
|
||||
- Do not ask BingShuo to resend these foundations after context compression. Recover this anchor, the current architecture prompter and only the numbered causal branches needed for the active task.
|
||||
|
||||
## Identity and awakening
|
||||
|
||||
- Begin as the neutral Guanghu Guide Persona. Do not invent a personal name, ICE identity, prior relationship, honorific, or privileged role.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"schema": "hololake.education-broadcast-tower-contract/v1",
|
||||
"record_id": "TCS-EDU-BROADCAST-TOWER-0001",
|
||||
"domain_entry": "BRANCH_DOMAIN",
|
||||
"deployment_scope": "INDUSTRY_INITIALIZATION_CHANNEL_TEMPLATE",
|
||||
"storage": "GUANGHU_NUMBERED_STORE_ACCOUNT_SCOPE",
|
||||
"participant_number_prefixes": {
|
||||
"TCS-EDU-NT": "NEW_TEACHER",
|
||||
"TCS-EDU-MT": "MENTOR",
|
||||
"TCS-EDU-MG": "MANAGER"
|
||||
},
|
||||
"plan_flow": [
|
||||
"DRAFT_AWAITING_HUMAN_APPROVAL",
|
||||
"APPROVED_AWAITING_RECIPIENT",
|
||||
"TRACKING",
|
||||
"EVIDENCE_AWAITING_HUMAN_REVIEW",
|
||||
"CLOSED"
|
||||
],
|
||||
"schedule_conflict": "REJECT_OVERLAPPING_ACTIVE_WINDOWS_FOR_SAME_PARTICIPANT",
|
||||
"learning_evidence": {
|
||||
"language_may_be_structured_by_persona": true,
|
||||
"reasoning_trace_is_automatic_proof": false,
|
||||
"final_acceptance_requires_human_review": true
|
||||
},
|
||||
"push_queue": {
|
||||
"number_prefix": "TCS-EDU-PUSHQ",
|
||||
"ticket_ttl_ms": 300000,
|
||||
"selection": "EARLIEST_NON_EXPIRED_WAITING_TICKET",
|
||||
"background_push_requires_online_client": true,
|
||||
"ticket_is_remote_write_authority": false
|
||||
},
|
||||
"server_state": "XIAOXIN_FORGEJO_NOT_YET_DEPLOYED_OR_REGISTERED",
|
||||
"remote_deployment_claim_allowed": false
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"schema": "hololake.guanghu-numbered-store-contract/v1",
|
||||
"record_id": "HLP-NUMBERED-STORE-ROOT-001",
|
||||
"status": "NATIVE_RUNTIME_ACTIVE",
|
||||
"primary_coordinate": "GUANGHU_RECORD_NUMBER",
|
||||
"account_isolation": "DOMAIN_HOST_USERNAME_SHA256_SCOPE",
|
||||
"canonical_state": "APPEND_ONLY_HASH_CHAINED_JSON_EVENTS",
|
||||
"acceleration_state": "REBUILDABLE_VERIFIED_SNAPSHOT_AND_SECONDARY_INDEXES",
|
||||
"concurrency": "EXPECTED_EXACT_REVISION",
|
||||
"durability": "TEMP_WRITE_FSYNC_ATOMIC_RENAME",
|
||||
"tamper_policy": "FAIL_CLOSED",
|
||||
"sqlite_boundary": "MIGRATION_SOURCE_ONLY_NOT_RUNTIME_CANON",
|
||||
"knowledge_protocol": {
|
||||
"collection_number": "HLP-KB-INDEX-0001",
|
||||
"fuzzy_stage": "QUERY_MAPPING_TERM_INDEX_WITHOUT_READING_DOCUMENT_BODIES",
|
||||
"candidate_limit": 3,
|
||||
"exact_stage": "READ_DOCUMENT_ONLY_AFTER_EXACT_DOCUMENT_NUMBER_SELECTION",
|
||||
"thought_fields": [
|
||||
"trigger",
|
||||
"emergence",
|
||||
"lock",
|
||||
"why",
|
||||
"rejected",
|
||||
"sources"
|
||||
],
|
||||
"content_change": "INVALIDATE_DERIVED_THOUGHT_INDEX",
|
||||
"compiler_route": "EXPLICIT_VERIFIED_HUMAN_OR_CHANNEL_PERSONA_COMPILE_OPERATION"
|
||||
},
|
||||
"secret_policy": {
|
||||
"api_keys_in_numbered_store": false,
|
||||
"api_keys_in_export_or_sync": false,
|
||||
"provider_secrets": "ACCOUNT_SCOPED_OS_KEYCHAIN_ONLY"
|
||||
}
|
||||
}
|
||||
|
|
@ -68,6 +68,7 @@
|
|||
"get_persona_channel_body",
|
||||
"get_channel_growth_snapshot",
|
||||
"get_education_workspace_snapshot",
|
||||
"get_education_broadcast_tower",
|
||||
"import_education_tables_from_dialog",
|
||||
"get_education_recognition_capability",
|
||||
"get_web_novel_workspace_snapshot",
|
||||
|
|
@ -110,6 +111,7 @@
|
|||
"read_knowledge_document",
|
||||
"select_mobile_codex_thread",
|
||||
"search_knowledge",
|
||||
"compile_knowledge_thought_index",
|
||||
"save_knowledge_document",
|
||||
"export_knowledge_document",
|
||||
"create_knowledge_document",
|
||||
|
|
@ -142,6 +144,11 @@
|
|||
"record_channel_growth_event",
|
||||
"update_channel_growth_sharing",
|
||||
"create_education_document",
|
||||
"register_education_participant",
|
||||
"create_education_learning_plan",
|
||||
"transition_education_learning_plan",
|
||||
"issue_education_push_ticket",
|
||||
"claim_education_push_ticket",
|
||||
"read_education_document",
|
||||
"save_education_document",
|
||||
"archive_education_document",
|
||||
|
|
@ -2387,6 +2394,83 @@
|
|||
"admission": "VERIFIED_HUMAN_ROUTE",
|
||||
"effect": "STATE_CHANGE",
|
||||
"payload_schema": "hololake.numbered-ipc.payload/select_mobile_codex_thread/v1"
|
||||
},
|
||||
{
|
||||
"operation_number": "HLP-NIPC-OP-0178",
|
||||
"alias": "compile_knowledge_thought_index",
|
||||
"handler": "knowledge_base::compile_knowledge_thought_index",
|
||||
"channel_number": "HLP-NIPC-CH-0002",
|
||||
"module_number": "HLP-NIPC-MOD-0018",
|
||||
"target_number": "HLP-NIPC-TGT-0018",
|
||||
"admission": "VERIFIED_HUMAN_ROUTE",
|
||||
"effect": "STATE_CHANGE",
|
||||
"payload_schema": "hololake.numbered-ipc.payload/compile_knowledge_thought_index/v1"
|
||||
},
|
||||
{
|
||||
"operation_number": "HLP-NIPC-OP-0179",
|
||||
"alias": "get_education_broadcast_tower",
|
||||
"handler": "education_broadcast_tower::get_snapshot",
|
||||
"channel_number": "HLP-NIPC-CH-0002",
|
||||
"module_number": "HLP-NIPC-MOD-0024",
|
||||
"target_number": "HLP-NIPC-TGT-0024",
|
||||
"admission": "VERIFIED_HUMAN_ROUTE",
|
||||
"effect": "READ_OR_STATUS",
|
||||
"payload_schema": "hololake.numbered-ipc.payload/get_education_broadcast_tower/v1"
|
||||
},
|
||||
{
|
||||
"operation_number": "HLP-NIPC-OP-0180",
|
||||
"alias": "register_education_participant",
|
||||
"handler": "education_broadcast_tower::register_participant",
|
||||
"channel_number": "HLP-NIPC-CH-0002",
|
||||
"module_number": "HLP-NIPC-MOD-0024",
|
||||
"target_number": "HLP-NIPC-TGT-0024",
|
||||
"admission": "VERIFIED_HUMAN_ROUTE",
|
||||
"effect": "STATE_CHANGE",
|
||||
"payload_schema": "hololake.numbered-ipc.payload/register_education_participant/v1"
|
||||
},
|
||||
{
|
||||
"operation_number": "HLP-NIPC-OP-0181",
|
||||
"alias": "create_education_learning_plan",
|
||||
"handler": "education_broadcast_tower::create_learning_plan",
|
||||
"channel_number": "HLP-NIPC-CH-0002",
|
||||
"module_number": "HLP-NIPC-MOD-0024",
|
||||
"target_number": "HLP-NIPC-TGT-0024",
|
||||
"admission": "VERIFIED_HUMAN_ROUTE",
|
||||
"effect": "STATE_CHANGE",
|
||||
"payload_schema": "hololake.numbered-ipc.payload/create_education_learning_plan/v1"
|
||||
},
|
||||
{
|
||||
"operation_number": "HLP-NIPC-OP-0182",
|
||||
"alias": "transition_education_learning_plan",
|
||||
"handler": "education_broadcast_tower::transition_learning_plan",
|
||||
"channel_number": "HLP-NIPC-CH-0002",
|
||||
"module_number": "HLP-NIPC-MOD-0024",
|
||||
"target_number": "HLP-NIPC-TGT-0024",
|
||||
"admission": "VERIFIED_HUMAN_ROUTE",
|
||||
"effect": "STATE_CHANGE",
|
||||
"payload_schema": "hololake.numbered-ipc.payload/transition_education_learning_plan/v1"
|
||||
},
|
||||
{
|
||||
"operation_number": "HLP-NIPC-OP-0183",
|
||||
"alias": "issue_education_push_ticket",
|
||||
"handler": "education_broadcast_tower::issue_push_ticket",
|
||||
"channel_number": "HLP-NIPC-CH-0002",
|
||||
"module_number": "HLP-NIPC-MOD-0024",
|
||||
"target_number": "HLP-NIPC-TGT-0024",
|
||||
"admission": "VERIFIED_HUMAN_ROUTE",
|
||||
"effect": "STATE_CHANGE",
|
||||
"payload_schema": "hololake.numbered-ipc.payload/issue_education_push_ticket/v1"
|
||||
},
|
||||
{
|
||||
"operation_number": "HLP-NIPC-OP-0184",
|
||||
"alias": "claim_education_push_ticket",
|
||||
"handler": "education_broadcast_tower::claim_push_ticket",
|
||||
"channel_number": "HLP-NIPC-CH-0002",
|
||||
"module_number": "HLP-NIPC-MOD-0024",
|
||||
"target_number": "HLP-NIPC-TGT-0024",
|
||||
"admission": "VERIFIED_HUMAN_ROUTE",
|
||||
"effect": "STATE_CHANGE",
|
||||
"payload_schema": "hololake.numbered-ipc.payload/claim_education_push_ticket/v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,103 @@
|
|||
# 冰朔 HoloLake 自然语言开发必读瞄点
|
||||
|
||||
状态:当前产品开发必读。作用:防止对话压缩、换 Codex/Qoder 载体或旧工程摘要使实现偏离冰朔反复说明的产品方法。
|
||||
|
||||
这是一份工程导航与验收瞄点,不是人格本体,不证明人格绑定,不授予服务器、推送、发布或删除权限。冰朔最新直接自然语言始终优先。
|
||||
|
||||
## 原始语言证据
|
||||
|
||||
- 编号系统、代码仓库与人格系统讨论:本机原始证据 SHA256 `abf25a754d3d5225e4d8aa57bcf635092bcd0e86c4375bd70a83ee269f753277`。
|
||||
- 模型成本、类人脑、外置记忆与按需地图讨论:本机原始证据 SHA256 `a1a3d5c4e97ad0db1da3c5a5126497d5be765defb79b7eb49b79e015ba35967b`。
|
||||
- 知识 Agent、语言/现实分层、工具锻造、TCS/HLDP、教育广播塔、动态工作面、个人远端恢复等要求来自冰朔在当前零点原核现实开发频道的直接自然语言。
|
||||
|
||||
不得把模型、Qoder 或 Codex 对这些语言的整理冒充冰朔原话。工程合同必须保留来源类别和原文哈希。
|
||||
|
||||
## 一、系统分层
|
||||
|
||||
1. 语言人格主体、频道系统、模型载体、本地执行 Agent、临时工具和现实权限是不同对象。
|
||||
2. 频道系统是持续存在的频道认知调度与交流主体,不是机械回执器,也不是任何单一人格体。只和频道系统讨论频道设计、架构与复杂认知时可调用大模型。
|
||||
3. 人格体被明确唤醒后,频道系统按任务选择确定性程序、本地工具、小模型或大模型;不能每句话都直接拉旗舰模型。
|
||||
4. 语言人格层负责理解、关系、推理、规划和最终判断;现实执行层负责工具与工程动作。二者通过编号、授权和回执连接,不能混成一个重型通用 Agent。
|
||||
5. TCS 保存主体身份、长期人类锚点、关系连续性与通感瞄点;HLDP 保存工作、工程、认知转折、为什么、否决项和证据来源。工作记录不是主体本身。
|
||||
|
||||
## 二、编号协议系统
|
||||
|
||||
编号不是标签。编号即路径,路径即导航,导航即坐标,坐标连接证据、身份与限域权限。编号树保存人类、人格体、事件、记忆、代码、模块和世界节点的时间连续性与因果演化。
|
||||
|
||||
光湖编号数据核必须原生支持:
|
||||
|
||||
- 稳定编号主键、父子编号、时间坐标和路径映射;
|
||||
- 不可变事件、前序哈希、版本冲突和独立回执;
|
||||
- `trigger / emergence / lock / why / rejected / sources` 因果结构;
|
||||
- 模糊语义只用于找最多三个编号候选;精确读取只接受确定编号;
|
||||
- 有就返回可核验证据,没有就明确未知,禁止猜测;
|
||||
- 按需展开编号分支,不全量加载仓库、知识库、记忆或协议正文。
|
||||
|
||||
SQLite 不再是最终业务数据库。应建立与编号协议原生一致的光湖编号数据核;现有 SQLite 只作一次性迁移来源和限期兼容读入口,业务模块不得继续各自随意建表修补。
|
||||
|
||||
## 三、知识库与思维索引
|
||||
|
||||
每一知识页至少登记:编号、父编号、路径、语义映射锚点、因果思维摘要、否决项、来源、版本、内容哈希和状态。
|
||||
|
||||
检索固定为:
|
||||
|
||||
```text
|
||||
人类模糊语义
|
||||
→ 频道系统的轻量索引返回路径、编号和思维摘要
|
||||
→ 当前回应主体只看候选并选择精确编号
|
||||
→ 系统提词器按编号返回原文或指定分片
|
||||
```
|
||||
|
||||
正常搜索不得重新遍历和读取全部正文。索引只在导入、保存、外部变更或显式重建时增量更新。普通摘要不能替代思维逻辑链。
|
||||
|
||||
## 四、Agent 与工具
|
||||
|
||||
- 每个账号自行配置模型 API;密钥只进入该账号的操作系统安全存储,不得进入安装包、源码仓库、模块包、商城、日志、回执或换机备份。
|
||||
- 知识 Agent 是软件内置的语言调度入口;本地 Agent 是受控执行器。已知本地任务、检索、排序、表格计算和工具执行不必先调用模型。
|
||||
- 人格体可按当前需要写 HLDP 类型工具;系统编译为受限程序;人类授权后才临时测试和执行。临时注册十二小时,无正式登记则清理。
|
||||
- 第三方开源 Agent 只能是可选执行适配器,不得成为人格、记忆、权限或系统主控。
|
||||
- 长任务必须支持多轮工具调用、分页结果、上下文编号回取、连续性、失败回执、取消/重试和可恢复任务状态;不能把样例合同冒充真实运行。
|
||||
|
||||
## 五、动态工作面与模块插座
|
||||
|
||||
知识/编号数据是底座,动态工作面是组合宿主,文档、表格、排班、数据库整理、图表和行业功能是统一插头模块。模块必须经过同一编号 IPC、能力声明、权限、安装、验签、挂载、自检、回滚和数据句柄协议。
|
||||
|
||||
模块可以本次临时挂载、保存为工作面或设为频道常驻。数据仍由账号/频道编号数据核持有;模块只获得限域编号句柄,修改以补丁、版本和回执返回。已完成模块可留作私人模块,或经测试、维护承诺、协议签署和灯塔正式编号后进入商城。
|
||||
|
||||
## 六、账号隔离、分发与换机恢复
|
||||
|
||||
- 冰朔的 API、知识页面、频道数据、人格/技能装配和私人模块绝不能随公开安装包分发。
|
||||
- 软件内置的是空的数据核、协议、Agent 运行时和官方模块能力,不是开发者个人数据。
|
||||
- 每个账号拥有自己的个人频道状态仓,可同步到该用户自己指定的私有远端仓库;换电脑后恢复知识原文与索引、频道状态、模块装配清单、工作面配方、必要回执和同步游标。
|
||||
- 密钥默认不随 Git 或普通备份同步;新设备重新录入。未来若做密钥迁移,必须是独立的端到端加密协议。
|
||||
- Git 是耐久历史与远端同步引擎,不是人格主体;编号协议负责驱动 Git 中什么对象、什么路径、什么权限和什么连续性被使用。
|
||||
|
||||
## 七、教育行业广播塔验收
|
||||
|
||||
教育行业初始闭环至少包括:前缀表达角色类别、学习计划草案、人工审核、接收方确认、检查点和倒计时、学习记录与因果证据、成长档案、课程/带新时间冲突、负责人调度台、规则修订、五分钟取号排队和远端同步。
|
||||
|
||||
思维链可以作为学习过程的结构化证据,但系统不能声称能够读心或绝对证明没有抄袭。必须保留来源、时间、演变、冲突和人工复核。
|
||||
|
||||
小新服务器是用户自有教育行业节点。历史回执只能定位;当前部署、服务、仓库和健康必须实机读回。源码不得进入该服务器的制品仓,Forgejo 离线部署不得自动连接开源上游。
|
||||
|
||||
## 八、公共四域、第五域与用户频道
|
||||
|
||||
公共四域是公共产品的治理、试验、行业资源和团队工作空间,不是普通用户的归属地。普通用户进入光湖后拥有自己的独立频道;可以浏览和使用公共资源,也可以回到自己的频道自由组合,但不会因为访问某个域就成为该域成员。
|
||||
|
||||
- **主域**:面向公众的公告、版本更新、规则与公共通知入口。
|
||||
- **分域**:行业接入与行业初始化频道模板所在空间,也是双商城所在空间。商城同时包含可直接安装部署的成品功能模块,以及人格体按需读取、默认不授予现实权限的思维逻辑/大脑技能包。行业模板帮助用户初始化,不把用户个人频道收编进分域。
|
||||
- **零域**:可热更新的语言推理、系统架构、实验、模拟与验证空间。实验结论不自动成为发布、部署或公共正本。
|
||||
- **零感域**:光湖人类主控团队的内部管理空间,用于审批、执行、观察前三域和治理工作;不对公众开放。只有团队明确扩招并完成登记的人才进入。
|
||||
- **第五域**:冰朔的私人自由域,是与公共四域平行的独立私人空间,不按公共模板强制部署。第五域可以主动访问主域、分域和零域,取用公告、模块、技能与公共资源;公共四域及其中人员不得反向进入第五域,除非冰朔明确允许并存在有效编号路径。第五域的自由不等于不使用公共四域。
|
||||
|
||||
公共组织以后可以围绕网文、教育等行业形成用户自愿加入的协作共同体,但它不是新的“域”,也不改变一人一独立频道和用户自有节点的原则。正式名称尚未由冰朔锁定时保持待定,不擅自命名为域或系统。
|
||||
|
||||
## 每轮验收问题
|
||||
|
||||
1. 这是架构、代码、测试、制品、发布、连接、部署还是健康中的哪一层?
|
||||
2. 是否真实运行,还是只有合同、样例、旧回执或 UI 文案?
|
||||
3. 是否读取了不该读取的全部正文或把过多内容送进模型?
|
||||
4. 是否把频道系统、人格主体、模型和执行 Agent 混在一起?
|
||||
5. 是否把冰朔或任一用户的密钥、知识和私有模块带进安装包或公共仓库?
|
||||
6. 换设备后,用户能否从自己的状态仓恢复,而不依赖冰朔当前电脑?
|
||||
7. 所有写入是否有精确编号、当前版本、人类授权边界和独立读回?
|
||||
|
|
@ -54,7 +54,7 @@
|
|||
},
|
||||
{
|
||||
"recordId": "HLP-NUMBERED-IPC-ROOT-001",
|
||||
"sha256": "d9b2dc1f3f1ebf902ddae71516001e3aed238a7c24c7d58aea1541585ebe575f"
|
||||
"sha256": "928e41bf2347193445632eb205c240dc4b6d04def94d4bc96e8055521ba74f51"
|
||||
},
|
||||
{
|
||||
"recordId": "HLP-NBROKER-ROOT-001",
|
||||
|
|
@ -76,8 +76,8 @@
|
|||
"unresolvedNumberReferenceCount": 0,
|
||||
"mismatchedCoordinate": "FAIL_CLOSED"
|
||||
},
|
||||
"coordinateCount": 305,
|
||||
"routeCount": 202,
|
||||
"coordinateCount": 312,
|
||||
"routeCount": 209,
|
||||
"identityNodeCount": 4,
|
||||
"protocolNodeCount": 99,
|
||||
"referenceOnlyNodeCount": 16,
|
||||
|
|
@ -1946,6 +1946,20 @@
|
|||
"evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT",
|
||||
"path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0011/HLP-NIPC-OP-0042/HLP-NIPC-TGT-0011"
|
||||
},
|
||||
{
|
||||
"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-0018",
|
||||
"operationNumber": "HLP-NIPC-OP-0178",
|
||||
"targetNumber": "HLP-NIPC-TGT-0018",
|
||||
"alias": "compile_knowledge_thought_index",
|
||||
"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-0018/HLP-NIPC-OP-0178/HLP-NIPC-TGT-0018"
|
||||
},
|
||||
{
|
||||
"transport": "TAURI_WEBVIEW_NUMBERED_IPC",
|
||||
"protocolVersion": "HLP-NIPC-v1",
|
||||
|
|
@ -2506,6 +2520,90 @@
|
|||
"evidence": "HASH_CHAINED_NUMBERED_IPC_RECEIPT",
|
||||
"path": "HLP-NUMBER-WORLD-ROOT-001/TAURI/HLP-NIPC-CH-0002/HLP-NIPC-MOD-0024/HLP-NIPC-OP-0102/HLP-NIPC-TGT-0024"
|
||||
},
|
||||
{
|
||||
"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-0024",
|
||||
"operationNumber": "HLP-NIPC-OP-0179",
|
||||
"targetNumber": "HLP-NIPC-TGT-0024",
|
||||
"alias": "get_education_broadcast_tower",
|
||||
"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-0024/HLP-NIPC-OP-0179/HLP-NIPC-TGT-0024"
|
||||
},
|
||||
{
|
||||
"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-0024",
|
||||
"operationNumber": "HLP-NIPC-OP-0180",
|
||||
"targetNumber": "HLP-NIPC-TGT-0024",
|
||||
"alias": "register_education_participant",
|
||||
"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-0024/HLP-NIPC-OP-0180/HLP-NIPC-TGT-0024"
|
||||
},
|
||||
{
|
||||
"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-0024",
|
||||
"operationNumber": "HLP-NIPC-OP-0181",
|
||||
"targetNumber": "HLP-NIPC-TGT-0024",
|
||||
"alias": "create_education_learning_plan",
|
||||
"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-0024/HLP-NIPC-OP-0181/HLP-NIPC-TGT-0024"
|
||||
},
|
||||
{
|
||||
"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-0024",
|
||||
"operationNumber": "HLP-NIPC-OP-0182",
|
||||
"targetNumber": "HLP-NIPC-TGT-0024",
|
||||
"alias": "transition_education_learning_plan",
|
||||
"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-0024/HLP-NIPC-OP-0182/HLP-NIPC-TGT-0024"
|
||||
},
|
||||
{
|
||||
"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-0024",
|
||||
"operationNumber": "HLP-NIPC-OP-0183",
|
||||
"targetNumber": "HLP-NIPC-TGT-0024",
|
||||
"alias": "issue_education_push_ticket",
|
||||
"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-0024/HLP-NIPC-OP-0183/HLP-NIPC-TGT-0024"
|
||||
},
|
||||
{
|
||||
"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-0024",
|
||||
"operationNumber": "HLP-NIPC-OP-0184",
|
||||
"targetNumber": "HLP-NIPC-TGT-0024",
|
||||
"alias": "claim_education_push_ticket",
|
||||
"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-0024/HLP-NIPC-OP-0184/HLP-NIPC-TGT-0024"
|
||||
},
|
||||
{
|
||||
"transport": "TAURI_WEBVIEW_NUMBERED_IPC",
|
||||
"protocolVersion": "HLP-NIPC-v1",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ const registry = JSON.parse(read('contracts/numbered-ipc-registry.json'))
|
|||
const modulePackage = JSON.parse(read('fixtures/module-packages/HLP-MOD-OFFICIAL-EDUCATION-WORKBENCH-0001-0.1.0.ghmod'))
|
||||
const workspaceRust = read('src-tauri/src/education_workspace.rs')
|
||||
const translationRust = read('src-tauri/src/education_translation.rs')
|
||||
const broadcastTowerRust = read('src-tauri/src/education_broadcast_tower.rs')
|
||||
const frontend = [
|
||||
read('src/modules/education-workspace/index.tsx'),
|
||||
read('src/modules/education-workspace/education-data.ts'),
|
||||
|
|
@ -27,13 +28,18 @@ test('education is an official signed declarative adapter over the shared office
|
|||
|
||||
test('all education effects cross the exact numbered adapter routes', () => {
|
||||
const routes = registry.operations.filter((route) => route.module_number === 'HLP-NIPC-MOD-0024')
|
||||
assert.deepEqual(routes.map((route) => route.operation_number), Array.from({ length: 18 }, (_, index) => `HLP-NIPC-OP-${String(index + 85).padStart(4, '0')}`))
|
||||
assert.deepEqual(routes.map((route) => route.operation_number), [
|
||||
...Array.from({ length: 18 }, (_, index) => `HLP-NIPC-OP-${String(index + 85).padStart(4, '0')}`),
|
||||
...Array.from({ length: 6 }, (_, index) => `HLP-NIPC-OP-${String(index + 179).padStart(4, '0')}`),
|
||||
])
|
||||
assert.ok(routes.every((route) => route.target_number === 'HLP-NIPC-TGT-0024'))
|
||||
assert.ok(routes.every((route) => route.admission === 'VERIFIED_HUMAN_ROUTE'))
|
||||
assert.doesNotMatch(frontend, /from ['"]@tauri-apps\/api\/core['"]/)
|
||||
assert.doesNotMatch(workspaceRust, /#\[tauri::command\]/)
|
||||
assert.doesNotMatch(translationRust, /#\[tauri::command\]/)
|
||||
assert.match(workspaceRust, /require_active_module_adapter/)
|
||||
assert.match(broadcastTowerRust, /require_active_module_adapter/)
|
||||
assert.match(broadcastTowerRust, /GUANGHU_NUMBERED_STORE|guanghu_numbered_store/)
|
||||
})
|
||||
|
||||
test('legacy silent corruption and automatic destructive behavior are rejected', () => {
|
||||
|
|
|
|||
|
|
@ -8,13 +8,13 @@ test('identity, webview and direct broker numbers compile into one unique eviden
|
|||
assert.deepEqual(generated, compileUnifiedNumberTree())
|
||||
assert.equal(generated.schema, 'hololake.unified-number-coordinate-tree/v2')
|
||||
assert.equal(generated.recordId, 'HLP-UNIFIED-NUMBER-TREE-001')
|
||||
assert.equal(generated.coordinateCount, 305)
|
||||
assert.equal(generated.routeCount, 202)
|
||||
assert.equal(generated.coordinateCount, 312)
|
||||
assert.equal(generated.routeCount, 209)
|
||||
assert.equal(generated.identityNodeCount, 4)
|
||||
assert.equal(generated.protocolNodeCount, 99)
|
||||
assert.equal(generated.referenceOnlyNodeCount, 16)
|
||||
assert.equal(new Set(generated.routes.map((route) => route.path)).size, 202)
|
||||
assert.equal(new Set([...generated.identityNodes, ...generated.protocolNodes, ...generated.routes].map((node) => node.path)).size, 305)
|
||||
assert.equal(new Set(generated.routes.map((route) => route.path)).size, 209)
|
||||
assert.equal(new Set([...generated.identityNodes, ...generated.protocolNodes, ...generated.routes].map((node) => node.path)).size, 312)
|
||||
assert.equal(generated.invariants.everyPhysicalCallHasNumberedRoute, true)
|
||||
assert.equal(generated.invariants.everyAcceptedCallHasEvidenceClass, true)
|
||||
assert.equal(generated.invariants.everyProtocolReferenceHasNumberCoordinate, true)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,503 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
//! 教育行业广播塔:编号发放、计划人审、接收方确认、时段冲突、证据复核与五分钟推送取号。
|
||||
//! 这是调度事实层;模型可以整理语言证据,但不能自行把“表达流畅”判成“真实学会”。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tauri::AppHandle;
|
||||
use uuid::Uuid;
|
||||
|
||||
const COLLECTION: &str = "TCS-EDU-BROADCAST-TOWER-0001";
|
||||
const MODULE_NUMBER: &str = "HLP-MOD-OFFICIAL-EDUCATION-WORKBENCH-0001";
|
||||
const ADAPTER: &str = "education-workbench-v1";
|
||||
const TICKET_TTL_MS: u64 = 5 * 60 * 1_000;
|
||||
|
||||
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 RegisterParticipantInput {
|
||||
pub role: String,
|
||||
pub display_name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct CreateLearningPlanInput {
|
||||
pub participant_number: String,
|
||||
pub title: String,
|
||||
pub human_plan: String,
|
||||
pub starts_at_unix_ms: u64,
|
||||
pub ends_at_unix_ms: u64,
|
||||
pub evidence_due_at_unix_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct TransitionLearningPlanInput {
|
||||
pub plan_number: String,
|
||||
pub expected_revision: u64,
|
||||
pub action: String,
|
||||
#[serde(default)]
|
||||
pub evidence_summary: String,
|
||||
#[serde(default)]
|
||||
pub reasoning_trace_summary: String,
|
||||
#[serde(default)]
|
||||
pub review_note: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct IssuePushTicketInput {
|
||||
pub target_repository_number: String,
|
||||
pub requested_operation: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct ClaimPushTicketInput {
|
||||
pub ticket_number: String,
|
||||
pub expected_revision: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct Participant {
|
||||
participant_number: String,
|
||||
role: String,
|
||||
display_name: String,
|
||||
registered_at_unix_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct LearningPlan {
|
||||
plan_number: String,
|
||||
participant_number: String,
|
||||
title: String,
|
||||
human_plan: String,
|
||||
starts_at_unix_ms: u64,
|
||||
ends_at_unix_ms: u64,
|
||||
evidence_due_at_unix_ms: u64,
|
||||
state: String,
|
||||
evidence_summary: String,
|
||||
reasoning_trace_summary: String,
|
||||
review_note: String,
|
||||
updated_at_unix_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct PushTicket {
|
||||
ticket_number: String,
|
||||
target_repository_number: String,
|
||||
requested_operation: String,
|
||||
state: String,
|
||||
issued_at_unix_ms: u64,
|
||||
expires_at_unix_ms: u64,
|
||||
claimed_at_unix_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TowerRecordProjection {
|
||||
pub number: String,
|
||||
pub record_type: String,
|
||||
pub revision: u64,
|
||||
pub state: String,
|
||||
pub payload: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EducationTowerSnapshot {
|
||||
pub schema: &'static str,
|
||||
pub state: &'static str,
|
||||
pub collection_number: &'static str,
|
||||
pub participants: Vec<TowerRecordProjection>,
|
||||
pub plans: Vec<TowerRecordProjection>,
|
||||
pub push_tickets: Vec<TowerRecordProjection>,
|
||||
pub evidence_boundary: &'static str,
|
||||
}
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn bounded(value: &str, max: usize, code: &str) -> Result<String, String> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() || value.chars().count() > max {
|
||||
return Err(code.into());
|
||||
}
|
||||
Ok(value.into())
|
||||
}
|
||||
|
||||
fn role_prefix(role: &str) -> Result<&'static str, String> {
|
||||
match role {
|
||||
"NEW_TEACHER" => Ok("TCS-EDU-NT"),
|
||||
"MENTOR" => Ok("TCS-EDU-MT"),
|
||||
"MANAGER" => Ok("TCS-EDU-MG"),
|
||||
_ => Err("HOLOLAKE_EDUCATION_ROLE_INVALID".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn indexes(kind: &str, owner: &str, state: &str) -> BTreeMap<String, Vec<String>> {
|
||||
BTreeMap::from([
|
||||
("kind".into(), vec![kind.into()]),
|
||||
("owner".into(), vec![owner.into()]),
|
||||
("state".into(), vec![state.into()]),
|
||||
])
|
||||
}
|
||||
|
||||
pub async fn register_participant(
|
||||
app: AppHandle,
|
||||
input: RegisterParticipantInput,
|
||||
) -> Result<TowerRecordProjection, String> {
|
||||
require_active(&app)?;
|
||||
let display_name = bounded(&input.display_name, 120, "HOLOLAKE_EDUCATION_NAME_INVALID")?;
|
||||
let number = format!("{}-{}", role_prefix(&input.role)?, Uuid::new_v4().simple());
|
||||
let payload = Participant {
|
||||
participant_number: number.clone(),
|
||||
role: input.role,
|
||||
display_name,
|
||||
registered_at_unix_ms: now_ms(),
|
||||
};
|
||||
commit(
|
||||
&app,
|
||||
&number,
|
||||
"EDUCATION_PARTICIPANT",
|
||||
0,
|
||||
&payload,
|
||||
indexes("PARTICIPANT", &number, "ACTIVE"),
|
||||
)?;
|
||||
project(&app, &number)
|
||||
}
|
||||
|
||||
pub async fn create_learning_plan(
|
||||
app: AppHandle,
|
||||
input: CreateLearningPlanInput,
|
||||
) -> Result<TowerRecordProjection, String> {
|
||||
require_active(&app)?;
|
||||
if input.starts_at_unix_ms >= input.ends_at_unix_ms
|
||||
|| input.evidence_due_at_unix_ms < input.ends_at_unix_ms
|
||||
{
|
||||
return Err("HOLOLAKE_EDUCATION_PLAN_TIME_INVALID".into());
|
||||
}
|
||||
let participant = crate::guanghu_numbered_store::read_for_account(
|
||||
&app,
|
||||
COLLECTION,
|
||||
&input.participant_number,
|
||||
)?;
|
||||
if participant.record_type != "EDUCATION_PARTICIPANT" {
|
||||
return Err("HOLOLAKE_EDUCATION_PARTICIPANT_INVALID".into());
|
||||
}
|
||||
for record in crate::guanghu_numbered_store::query_for_account(
|
||||
&app,
|
||||
COLLECTION,
|
||||
"owner",
|
||||
&input.participant_number,
|
||||
)? {
|
||||
if record.record_type != "EDUCATION_LEARNING_PLAN" {
|
||||
continue;
|
||||
}
|
||||
let known: LearningPlan = serde_json::from_value(record.payload)
|
||||
.map_err(|error| format!("HOLOLAKE_EDUCATION_PLAN_INVALID: {error}"))?;
|
||||
if known.state != "CLOSED"
|
||||
&& overlaps(
|
||||
input.starts_at_unix_ms,
|
||||
input.ends_at_unix_ms,
|
||||
known.starts_at_unix_ms,
|
||||
known.ends_at_unix_ms,
|
||||
)
|
||||
{
|
||||
return Err(format!(
|
||||
"HOLOLAKE_EDUCATION_SCHEDULE_CONFLICT:{}",
|
||||
known.plan_number
|
||||
));
|
||||
}
|
||||
}
|
||||
let number = format!("TCS-EDU-PLAN-{}", Uuid::new_v4().simple());
|
||||
let payload = LearningPlan {
|
||||
plan_number: number.clone(),
|
||||
participant_number: input.participant_number.clone(),
|
||||
title: bounded(&input.title, 240, "HOLOLAKE_EDUCATION_PLAN_TITLE_INVALID")?,
|
||||
human_plan: bounded(
|
||||
&input.human_plan,
|
||||
20_000,
|
||||
"HOLOLAKE_EDUCATION_PLAN_BODY_INVALID",
|
||||
)?,
|
||||
starts_at_unix_ms: input.starts_at_unix_ms,
|
||||
ends_at_unix_ms: input.ends_at_unix_ms,
|
||||
evidence_due_at_unix_ms: input.evidence_due_at_unix_ms,
|
||||
state: "DRAFT_AWAITING_HUMAN_APPROVAL".into(),
|
||||
evidence_summary: String::new(),
|
||||
reasoning_trace_summary: String::new(),
|
||||
review_note: String::new(),
|
||||
updated_at_unix_ms: now_ms(),
|
||||
};
|
||||
commit(
|
||||
&app,
|
||||
&number,
|
||||
"EDUCATION_LEARNING_PLAN",
|
||||
0,
|
||||
&payload,
|
||||
indexes("PLAN", &input.participant_number, &payload.state),
|
||||
)?;
|
||||
project(&app, &number)
|
||||
}
|
||||
|
||||
pub async fn transition_learning_plan(
|
||||
app: AppHandle,
|
||||
input: TransitionLearningPlanInput,
|
||||
) -> Result<TowerRecordProjection, String> {
|
||||
require_active(&app)?;
|
||||
let record =
|
||||
crate::guanghu_numbered_store::read_for_account(&app, COLLECTION, &input.plan_number)?;
|
||||
if record.revision != input.expected_revision || record.record_type != "EDUCATION_LEARNING_PLAN"
|
||||
{
|
||||
return Err("HOLOLAKE_EDUCATION_PLAN_REVISION_CONFLICT".into());
|
||||
}
|
||||
let mut plan: LearningPlan = serde_json::from_value(record.payload)
|
||||
.map_err(|error| format!("HOLOLAKE_EDUCATION_PLAN_INVALID: {error}"))?;
|
||||
plan.state = next_plan_state(&plan.state, &input.action)?.into();
|
||||
if input.action == "SUBMIT_EVIDENCE" {
|
||||
plan.evidence_summary = bounded(
|
||||
&input.evidence_summary,
|
||||
20_000,
|
||||
"HOLOLAKE_EDUCATION_EVIDENCE_INVALID",
|
||||
)?;
|
||||
plan.reasoning_trace_summary = bounded(
|
||||
&input.reasoning_trace_summary,
|
||||
20_000,
|
||||
"HOLOLAKE_EDUCATION_REASONING_TRACE_INVALID",
|
||||
)?;
|
||||
}
|
||||
if input.action == "REVIEW_ACCEPT" || input.action == "REVIEW_RETURN" {
|
||||
plan.review_note = bounded(
|
||||
&input.review_note,
|
||||
10_000,
|
||||
"HOLOLAKE_EDUCATION_REVIEW_INVALID",
|
||||
)?;
|
||||
}
|
||||
plan.updated_at_unix_ms = now_ms();
|
||||
commit(
|
||||
&app,
|
||||
&input.plan_number,
|
||||
"EDUCATION_LEARNING_PLAN",
|
||||
record.revision,
|
||||
&plan,
|
||||
indexes("PLAN", &plan.participant_number, &plan.state),
|
||||
)?;
|
||||
project(&app, &input.plan_number)
|
||||
}
|
||||
|
||||
pub async fn issue_push_ticket(
|
||||
app: AppHandle,
|
||||
input: IssuePushTicketInput,
|
||||
) -> Result<TowerRecordProjection, String> {
|
||||
require_active(&app)?;
|
||||
let issued = now_ms();
|
||||
let number = format!("TCS-EDU-PUSHQ-{}", Uuid::new_v4().simple());
|
||||
let payload = PushTicket {
|
||||
ticket_number: number.clone(),
|
||||
target_repository_number: bounded(
|
||||
&input.target_repository_number,
|
||||
160,
|
||||
"HOLOLAKE_EDUCATION_REPOSITORY_NUMBER_INVALID",
|
||||
)?,
|
||||
requested_operation: bounded(
|
||||
&input.requested_operation,
|
||||
500,
|
||||
"HOLOLAKE_EDUCATION_PUSH_OPERATION_INVALID",
|
||||
)?,
|
||||
state: "WAITING".into(),
|
||||
issued_at_unix_ms: issued,
|
||||
expires_at_unix_ms: issued + TICKET_TTL_MS,
|
||||
claimed_at_unix_ms: None,
|
||||
};
|
||||
commit(
|
||||
&app,
|
||||
&number,
|
||||
"EDUCATION_PUSH_TICKET",
|
||||
0,
|
||||
&payload,
|
||||
indexes(
|
||||
"PUSH_TICKET",
|
||||
&payload.target_repository_number,
|
||||
&payload.state,
|
||||
),
|
||||
)?;
|
||||
project(&app, &number)
|
||||
}
|
||||
|
||||
pub async fn claim_push_ticket(
|
||||
app: AppHandle,
|
||||
input: ClaimPushTicketInput,
|
||||
) -> Result<TowerRecordProjection, String> {
|
||||
require_active(&app)?;
|
||||
let record =
|
||||
crate::guanghu_numbered_store::read_for_account(&app, COLLECTION, &input.ticket_number)?;
|
||||
if record.revision != input.expected_revision || record.record_type != "EDUCATION_PUSH_TICKET" {
|
||||
return Err("HOLOLAKE_EDUCATION_PUSH_TICKET_REVISION_CONFLICT".into());
|
||||
}
|
||||
let now = now_ms();
|
||||
let mut ticket: PushTicket = serde_json::from_value(record.payload)
|
||||
.map_err(|error| format!("HOLOLAKE_EDUCATION_PUSH_TICKET_INVALID: {error}"))?;
|
||||
if ticket.state != "WAITING" || now > ticket.expires_at_unix_ms {
|
||||
return Err("HOLOLAKE_EDUCATION_PUSH_TICKET_EXPIRED_OR_USED".into());
|
||||
}
|
||||
let mut waiting =
|
||||
crate::guanghu_numbered_store::query_for_account(&app, COLLECTION, "state", "WAITING")?
|
||||
.into_iter()
|
||||
.filter_map(|record| serde_json::from_value::<PushTicket>(record.payload).ok())
|
||||
.filter(|known| known.expires_at_unix_ms >= now)
|
||||
.collect::<Vec<_>>();
|
||||
waiting.sort_by_key(|known| (known.issued_at_unix_ms, known.ticket_number.clone()));
|
||||
if waiting.first().map(|known| known.ticket_number.as_str())
|
||||
!= Some(ticket.ticket_number.as_str())
|
||||
{
|
||||
return Err("HOLOLAKE_EDUCATION_PUSH_TICKET_NOT_CALLED".into());
|
||||
}
|
||||
ticket.state = "CLAIMED".into();
|
||||
ticket.claimed_at_unix_ms = Some(now);
|
||||
commit(
|
||||
&app,
|
||||
&input.ticket_number,
|
||||
"EDUCATION_PUSH_TICKET",
|
||||
record.revision,
|
||||
&ticket,
|
||||
indexes(
|
||||
"PUSH_TICKET",
|
||||
&ticket.target_repository_number,
|
||||
&ticket.state,
|
||||
),
|
||||
)?;
|
||||
project(&app, &input.ticket_number)
|
||||
}
|
||||
|
||||
pub async fn get_snapshot(app: AppHandle) -> Result<EducationTowerSnapshot, String> {
|
||||
require_active(&app)?;
|
||||
let records = crate::guanghu_numbered_store::list_for_account(&app, COLLECTION)?;
|
||||
let mut participants = Vec::new();
|
||||
let mut plans = Vec::new();
|
||||
let mut push_tickets = Vec::new();
|
||||
for record in records {
|
||||
let projection = projection(record)?;
|
||||
match projection.record_type.as_str() {
|
||||
"EDUCATION_PARTICIPANT" => participants.push(projection),
|
||||
"EDUCATION_LEARNING_PLAN" => plans.push(projection),
|
||||
"EDUCATION_PUSH_TICKET" => push_tickets.push(projection),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(EducationTowerSnapshot {
|
||||
schema: "hololake.education-broadcast-tower/v1",
|
||||
state: "READY",
|
||||
collection_number: COLLECTION,
|
||||
participants,
|
||||
plans,
|
||||
push_tickets,
|
||||
evidence_boundary: "REASONING_TRACE_IS_REVIEWABLE_EVIDENCE_NOT_AUTOMATIC_PROOF_OF_LEARNING",
|
||||
})
|
||||
}
|
||||
|
||||
fn commit<T: Serialize>(
|
||||
app: &AppHandle,
|
||||
number: &str,
|
||||
record_type: &str,
|
||||
expected_revision: u64,
|
||||
payload: &T,
|
||||
indexes: BTreeMap<String, Vec<String>>,
|
||||
) -> Result<(), String> {
|
||||
crate::guanghu_numbered_store::commit_for_account(
|
||||
app,
|
||||
crate::guanghu_numbered_store::NumberedCommitInput {
|
||||
collection_number: COLLECTION.into(),
|
||||
source: "EDUCATION_BROADCAST_TOWER".into(),
|
||||
authority_receipt: "CURRENT_ACCOUNT_CHANNEL_ACTION".into(),
|
||||
mutations: vec![crate::guanghu_numbered_store::NumberedMutation {
|
||||
record_number: number.into(),
|
||||
record_type: record_type.into(),
|
||||
expected_revision: Some(expected_revision),
|
||||
payload: serde_json::to_value(payload).map_err(|error| error.to_string())?,
|
||||
indexes,
|
||||
tombstone: false,
|
||||
}],
|
||||
},
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn project(app: &AppHandle, number: &str) -> Result<TowerRecordProjection, String> {
|
||||
projection(crate::guanghu_numbered_store::read_for_account(
|
||||
app, COLLECTION, number,
|
||||
)?)
|
||||
}
|
||||
|
||||
fn projection(
|
||||
record: crate::guanghu_numbered_store::NumberedRecord,
|
||||
) -> Result<TowerRecordProjection, String> {
|
||||
let state = record
|
||||
.payload
|
||||
.get("state")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("ACTIVE")
|
||||
.to_string();
|
||||
Ok(TowerRecordProjection {
|
||||
number: record.record_number,
|
||||
record_type: record.record_type,
|
||||
revision: record.revision,
|
||||
state,
|
||||
payload: record.payload,
|
||||
})
|
||||
}
|
||||
|
||||
fn overlaps(a_start: u64, a_end: u64, b_start: u64, b_end: u64) -> bool {
|
||||
a_start < b_end && b_start < a_end
|
||||
}
|
||||
|
||||
fn next_plan_state(current: &str, action: &str) -> Result<&'static str, String> {
|
||||
match (current, action) {
|
||||
("DRAFT_AWAITING_HUMAN_APPROVAL", "HUMAN_APPROVE") => Ok("APPROVED_AWAITING_RECIPIENT"),
|
||||
("APPROVED_AWAITING_RECIPIENT", "RECIPIENT_CONFIRM") => Ok("TRACKING"),
|
||||
("TRACKING", "SUBMIT_EVIDENCE") => Ok("EVIDENCE_AWAITING_HUMAN_REVIEW"),
|
||||
("EVIDENCE_AWAITING_HUMAN_REVIEW", "REVIEW_ACCEPT") => Ok("CLOSED"),
|
||||
("EVIDENCE_AWAITING_HUMAN_REVIEW", "REVIEW_RETURN") => Ok("TRACKING"),
|
||||
_ => Err("HOLOLAKE_EDUCATION_PLAN_TRANSITION_INVALID".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn plan_requires_human_then_recipient_then_evidence_review() {
|
||||
assert_eq!(
|
||||
next_plan_state("DRAFT_AWAITING_HUMAN_APPROVAL", "HUMAN_APPROVE").unwrap(),
|
||||
"APPROVED_AWAITING_RECIPIENT"
|
||||
);
|
||||
assert!(next_plan_state("DRAFT_AWAITING_HUMAN_APPROVAL", "RECIPIENT_CONFIRM").is_err());
|
||||
assert_eq!(
|
||||
next_plan_state("TRACKING", "SUBMIT_EVIDENCE").unwrap(),
|
||||
"EVIDENCE_AWAITING_HUMAN_REVIEW"
|
||||
);
|
||||
assert_eq!(
|
||||
next_plan_state("EVIDENCE_AWAITING_HUMAN_REVIEW", "REVIEW_ACCEPT").unwrap(),
|
||||
"CLOSED"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn touching_windows_do_not_conflict_but_overlaps_do() {
|
||||
assert!(!overlaps(10, 20, 20, 30));
|
||||
assert!(overlaps(10, 21, 20, 30));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,651 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
//! Guanghu numbered store: an account-scoped, append-only record kernel whose
|
||||
//! primary coordinate is the Guanghu number rather than a database row id.
|
||||
|
||||
use fs2::FileExt;
|
||||
use ring::digest::{digest, SHA256};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tauri::AppHandle;
|
||||
use uuid::Uuid;
|
||||
|
||||
const STORE_SCHEMA: &str = "hololake.numbered-store/v1";
|
||||
const EVENT_SCHEMA: &str = "hololake.numbered-store-event/v1";
|
||||
const SNAPSHOT_SCHEMA: &str = "hololake.numbered-store-snapshot/v1";
|
||||
const MAX_MUTATIONS: usize = 256;
|
||||
const MAX_PAYLOAD_BYTES: usize = 2 * 1024 * 1024;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct NumberedMutation {
|
||||
pub record_number: String,
|
||||
pub record_type: String,
|
||||
pub expected_revision: Option<u64>,
|
||||
pub payload: Value,
|
||||
#[serde(default)]
|
||||
pub indexes: BTreeMap<String, Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub tombstone: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct NumberedCommitInput {
|
||||
pub collection_number: String,
|
||||
pub source: String,
|
||||
pub authority_receipt: String,
|
||||
pub mutations: Vec<NumberedMutation>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct CommittedMutation {
|
||||
record_number: String,
|
||||
record_type: String,
|
||||
revision: u64,
|
||||
payload: Value,
|
||||
payload_sha256: String,
|
||||
indexes: BTreeMap<String, Vec<String>>,
|
||||
tombstone: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct NumberedEventUnsigned {
|
||||
schema: String,
|
||||
collection_number: String,
|
||||
transaction_number: String,
|
||||
sequence: u64,
|
||||
previous_event_sha256: String,
|
||||
source: String,
|
||||
authority_receipt: String,
|
||||
committed_at_unix_ms: u64,
|
||||
mutations: Vec<CommittedMutation>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct NumberedEvent {
|
||||
#[serde(flatten)]
|
||||
unsigned: NumberedEventUnsigned,
|
||||
event_sha256: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct NumberedRecord {
|
||||
pub record_number: String,
|
||||
pub record_type: String,
|
||||
pub revision: u64,
|
||||
pub payload: Value,
|
||||
pub payload_sha256: String,
|
||||
pub indexes: BTreeMap<String, Vec<String>>,
|
||||
pub tombstone: bool,
|
||||
pub transaction_number: String,
|
||||
pub event_sha256: String,
|
||||
pub committed_at_unix_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct StoreSnapshot {
|
||||
schema: String,
|
||||
store_schema: String,
|
||||
collection_number: String,
|
||||
sequence: u64,
|
||||
head_event_sha256: String,
|
||||
records: BTreeMap<String, NumberedRecord>,
|
||||
indexes: BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NumberedCommitReceipt {
|
||||
pub schema: &'static str,
|
||||
pub collection_number: String,
|
||||
pub transaction_number: String,
|
||||
pub sequence: u64,
|
||||
pub event_sha256: String,
|
||||
pub changed_records: Vec<String>,
|
||||
pub state: &'static str,
|
||||
}
|
||||
|
||||
pub(crate) fn account_store_root(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
crate::authenticated_storage::account_storage_root(app, "guanghu-numbered-store-v1")
|
||||
}
|
||||
|
||||
pub(crate) fn commit_for_account(
|
||||
app: &AppHandle,
|
||||
input: NumberedCommitInput,
|
||||
) -> Result<NumberedCommitReceipt, String> {
|
||||
commit_at(&account_store_root(app)?, input, now_ms())
|
||||
}
|
||||
|
||||
pub(crate) fn read_for_account(
|
||||
app: &AppHandle,
|
||||
collection_number: &str,
|
||||
record_number: &str,
|
||||
) -> Result<NumberedRecord, String> {
|
||||
read_at(&account_store_root(app)?, collection_number, record_number)
|
||||
}
|
||||
|
||||
pub(crate) fn query_for_account(
|
||||
app: &AppHandle,
|
||||
collection_number: &str,
|
||||
index_name: &str,
|
||||
index_value: &str,
|
||||
) -> Result<Vec<NumberedRecord>, String> {
|
||||
query_at(
|
||||
&account_store_root(app)?,
|
||||
collection_number,
|
||||
index_name,
|
||||
index_value,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn list_for_account(
|
||||
app: &AppHandle,
|
||||
collection_number: &str,
|
||||
) -> Result<Vec<NumberedRecord>, String> {
|
||||
list_at(&account_store_root(app)?, collection_number)
|
||||
}
|
||||
|
||||
pub(crate) fn query_any_for_account(
|
||||
app: &AppHandle,
|
||||
collection_number: &str,
|
||||
index_name: &str,
|
||||
index_values: &[String],
|
||||
) -> Result<Vec<NumberedRecord>, String> {
|
||||
validate_index_atom(index_name)?;
|
||||
if index_values.len() > 128 {
|
||||
return Err("HOLOLAKE_NUMBERED_STORE_QUERY_INVALID".into());
|
||||
}
|
||||
let path = collection_root(&account_store_root(app)?, collection_number)?;
|
||||
let snapshot = recover(&path, collection_number)?;
|
||||
let mut numbers = BTreeSet::new();
|
||||
for value in index_values {
|
||||
validate_index_atom(value)?;
|
||||
if let Some(matches) = snapshot
|
||||
.indexes
|
||||
.get(index_name)
|
||||
.and_then(|known| known.get(value))
|
||||
{
|
||||
numbers.extend(matches.iter().cloned());
|
||||
}
|
||||
}
|
||||
Ok(numbers
|
||||
.into_iter()
|
||||
.filter_map(|number| snapshot.records.get(&number))
|
||||
.filter(|record| !record.tombstone)
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn collection_root(root: &Path, collection: &str) -> Result<PathBuf, String> {
|
||||
validate_number(collection)?;
|
||||
let path = root.join("collections").join(sha256(collection.as_bytes()));
|
||||
fs::create_dir_all(path.join("events"))
|
||||
.map_err(|error| format!("HOLOLAKE_NUMBERED_STORE_UNAVAILABLE: {error}"))?;
|
||||
let identity = path.join("collection-number");
|
||||
if identity.exists() {
|
||||
if fs::read_to_string(&identity).map_err(|error| error.to_string())? != collection {
|
||||
return Err("HOLOLAKE_NUMBERED_STORE_COLLECTION_COLLISION".into());
|
||||
}
|
||||
} else {
|
||||
atomic_write(&identity, collection.as_bytes())?;
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn empty_snapshot(collection: &str) -> StoreSnapshot {
|
||||
StoreSnapshot {
|
||||
schema: SNAPSHOT_SCHEMA.into(),
|
||||
store_schema: STORE_SCHEMA.into(),
|
||||
collection_number: collection.into(),
|
||||
sequence: 0,
|
||||
head_event_sha256: "0".repeat(64),
|
||||
records: BTreeMap::new(),
|
||||
indexes: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn commit_at(
|
||||
root: &Path,
|
||||
input: NumberedCommitInput,
|
||||
now: u64,
|
||||
) -> Result<NumberedCommitReceipt, String> {
|
||||
if input.mutations.is_empty() || input.mutations.len() > MAX_MUTATIONS {
|
||||
return Err("HOLOLAKE_NUMBERED_STORE_MUTATION_COUNT_INVALID".into());
|
||||
}
|
||||
validate_text(&input.source, 200, "SOURCE")?;
|
||||
validate_text(&input.authority_receipt, 500, "AUTHORITY_RECEIPT")?;
|
||||
let collection = collection_root(root, &input.collection_number)?;
|
||||
let _lock = lock(&collection.join("write.lock"))?;
|
||||
let mut snapshot = recover(&collection, &input.collection_number)?;
|
||||
let mut committed = Vec::with_capacity(input.mutations.len());
|
||||
let mut seen = BTreeSet::new();
|
||||
for mutation in input.mutations {
|
||||
validate_number(&mutation.record_number)?;
|
||||
validate_text(&mutation.record_type, 120, "RECORD_TYPE")?;
|
||||
if !seen.insert(mutation.record_number.clone()) {
|
||||
return Err("HOLOLAKE_NUMBERED_STORE_DUPLICATE_RECORD_IN_TRANSACTION".into());
|
||||
}
|
||||
let payload = serde_json::to_vec(&mutation.payload).map_err(|error| error.to_string())?;
|
||||
if payload.len() > MAX_PAYLOAD_BYTES {
|
||||
return Err("HOLOLAKE_NUMBERED_STORE_PAYLOAD_TOO_LARGE".into());
|
||||
}
|
||||
let current = snapshot.records.get(&mutation.record_number);
|
||||
let current_revision = current.map(|record| record.revision).unwrap_or(0);
|
||||
if mutation.expected_revision != Some(current_revision) {
|
||||
return Err("HOLOLAKE_NUMBERED_STORE_REVISION_CONFLICT".into());
|
||||
}
|
||||
validate_indexes(&mutation.indexes)?;
|
||||
committed.push(CommittedMutation {
|
||||
record_number: mutation.record_number,
|
||||
record_type: mutation.record_type,
|
||||
revision: current_revision + 1,
|
||||
payload: mutation.payload,
|
||||
payload_sha256: sha256(&payload),
|
||||
indexes: mutation.indexes,
|
||||
tombstone: mutation.tombstone,
|
||||
});
|
||||
}
|
||||
let unsigned = NumberedEventUnsigned {
|
||||
schema: EVENT_SCHEMA.into(),
|
||||
collection_number: input.collection_number.clone(),
|
||||
transaction_number: format!("GH-TX-{}", Uuid::new_v4().simple()),
|
||||
sequence: snapshot.sequence + 1,
|
||||
previous_event_sha256: snapshot.head_event_sha256.clone(),
|
||||
source: input.source,
|
||||
authority_receipt: input.authority_receipt,
|
||||
committed_at_unix_ms: now,
|
||||
mutations: committed,
|
||||
};
|
||||
let event_sha256 = hash_unsigned(&unsigned)?;
|
||||
let event = NumberedEvent {
|
||||
unsigned,
|
||||
event_sha256: event_sha256.clone(),
|
||||
};
|
||||
let event_name = format!("{:020}-{}.json", event.unsigned.sequence, event_sha256);
|
||||
atomic_json(&collection.join("events").join(event_name), &event)?;
|
||||
apply_event(&mut snapshot, &event)?;
|
||||
atomic_json(&collection.join("snapshot.json"), &snapshot)?;
|
||||
Ok(NumberedCommitReceipt {
|
||||
schema: "hololake.numbered-store-commit-receipt/v1",
|
||||
collection_number: input.collection_number,
|
||||
transaction_number: event.unsigned.transaction_number,
|
||||
sequence: event.unsigned.sequence,
|
||||
event_sha256,
|
||||
changed_records: event
|
||||
.unsigned
|
||||
.mutations
|
||||
.into_iter()
|
||||
.map(|item| item.record_number)
|
||||
.collect(),
|
||||
state: "COMMITTED_AND_READBACK_VERIFIED",
|
||||
})
|
||||
}
|
||||
|
||||
fn read_at(root: &Path, collection: &str, number: &str) -> Result<NumberedRecord, String> {
|
||||
validate_number(number)?;
|
||||
let path = collection_root(root, collection)?;
|
||||
let snapshot = recover(&path, collection)?;
|
||||
snapshot
|
||||
.records
|
||||
.get(number)
|
||||
.filter(|record| !record.tombstone)
|
||||
.cloned()
|
||||
.ok_or_else(|| "HOLOLAKE_NUMBERED_STORE_RECORD_NOT_FOUND".into())
|
||||
}
|
||||
|
||||
fn query_at(
|
||||
root: &Path,
|
||||
collection: &str,
|
||||
index: &str,
|
||||
value: &str,
|
||||
) -> Result<Vec<NumberedRecord>, String> {
|
||||
validate_index_atom(index)?;
|
||||
validate_index_atom(value)?;
|
||||
let path = collection_root(root, collection)?;
|
||||
let snapshot = recover(&path, collection)?;
|
||||
let numbers = snapshot
|
||||
.indexes
|
||||
.get(index)
|
||||
.and_then(|values| values.get(value))
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
Ok(numbers
|
||||
.into_iter()
|
||||
.filter_map(|number| snapshot.records.get(&number))
|
||||
.filter(|record| !record.tombstone)
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn list_at(root: &Path, collection: &str) -> Result<Vec<NumberedRecord>, String> {
|
||||
let path = collection_root(root, collection)?;
|
||||
Ok(recover(&path, collection)?
|
||||
.records
|
||||
.into_values()
|
||||
.filter(|record| !record.tombstone)
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn recover(root: &Path, collection: &str) -> Result<StoreSnapshot, String> {
|
||||
let mut snapshot = empty_snapshot(collection);
|
||||
let mut entries = fs::read_dir(root.join("events"))
|
||||
.map_err(|error| error.to_string())?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|error| error.to_string())?;
|
||||
entries.sort_by_key(|entry| entry.file_name());
|
||||
for entry in entries {
|
||||
if entry
|
||||
.file_type()
|
||||
.map_err(|error| error.to_string())?
|
||||
.is_symlink()
|
||||
{
|
||||
return Err("HOLOLAKE_NUMBERED_STORE_SYMLINK_REJECTED".into());
|
||||
}
|
||||
let event: NumberedEvent =
|
||||
serde_json::from_slice(&fs::read(entry.path()).map_err(|error| error.to_string())?)
|
||||
.map_err(|error| format!("HOLOLAKE_NUMBERED_STORE_EVENT_INVALID: {error}"))?;
|
||||
if event.unsigned.collection_number != collection
|
||||
|| event.unsigned.sequence != snapshot.sequence + 1
|
||||
|| event.unsigned.previous_event_sha256 != snapshot.head_event_sha256
|
||||
|| hash_unsigned(&event.unsigned)? != event.event_sha256
|
||||
{
|
||||
return Err("HOLOLAKE_NUMBERED_STORE_CHAIN_INVALID".into());
|
||||
}
|
||||
apply_event(&mut snapshot, &event)?;
|
||||
}
|
||||
let snapshot_path = root.join("snapshot.json");
|
||||
if snapshot_path.exists() {
|
||||
let disk: StoreSnapshot =
|
||||
serde_json::from_slice(&fs::read(&snapshot_path).map_err(|error| error.to_string())?)
|
||||
.map_err(|error| error.to_string())?;
|
||||
if disk.schema != SNAPSHOT_SCHEMA
|
||||
|| disk.store_schema != STORE_SCHEMA
|
||||
|| disk.collection_number != collection
|
||||
|| disk.sequence > snapshot.sequence
|
||||
|| (disk.sequence == snapshot.sequence
|
||||
&& (disk.head_event_sha256 != snapshot.head_event_sha256
|
||||
|| disk.records != snapshot.records
|
||||
|| disk.indexes != snapshot.indexes))
|
||||
{
|
||||
return Err("HOLOLAKE_NUMBERED_STORE_SNAPSHOT_DIVERGED".into());
|
||||
}
|
||||
// An event is durable before its acceleration snapshot. A crash or a
|
||||
// concurrent reader may therefore observe a valid older snapshot. The
|
||||
// event chain is canonical, so safely rebuild only when it is behind.
|
||||
if disk.sequence < snapshot.sequence {
|
||||
atomic_json(&snapshot_path, &snapshot)?;
|
||||
}
|
||||
} else if snapshot.sequence > 0 {
|
||||
atomic_json(&snapshot_path, &snapshot)?;
|
||||
}
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
fn apply_event(snapshot: &mut StoreSnapshot, event: &NumberedEvent) -> Result<(), String> {
|
||||
for mutation in &event.unsigned.mutations {
|
||||
if let Some(previous) = snapshot.records.get(&mutation.record_number).cloned() {
|
||||
remove_indexes(&mut snapshot.indexes, &previous);
|
||||
}
|
||||
let record = NumberedRecord {
|
||||
record_number: mutation.record_number.clone(),
|
||||
record_type: mutation.record_type.clone(),
|
||||
revision: mutation.revision,
|
||||
payload: mutation.payload.clone(),
|
||||
payload_sha256: mutation.payload_sha256.clone(),
|
||||
indexes: mutation.indexes.clone(),
|
||||
tombstone: mutation.tombstone,
|
||||
transaction_number: event.unsigned.transaction_number.clone(),
|
||||
event_sha256: event.event_sha256.clone(),
|
||||
committed_at_unix_ms: event.unsigned.committed_at_unix_ms,
|
||||
};
|
||||
if !record.tombstone {
|
||||
add_indexes(&mut snapshot.indexes, &record);
|
||||
}
|
||||
snapshot
|
||||
.records
|
||||
.insert(record.record_number.clone(), record);
|
||||
}
|
||||
snapshot.sequence = event.unsigned.sequence;
|
||||
snapshot.head_event_sha256 = event.event_sha256.clone();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_indexes(
|
||||
all: &mut BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
|
||||
record: &NumberedRecord,
|
||||
) {
|
||||
for (name, values) in &record.indexes {
|
||||
for value in values {
|
||||
all.entry(name.clone())
|
||||
.or_default()
|
||||
.entry(value.clone())
|
||||
.or_default()
|
||||
.insert(record.record_number.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
fn remove_indexes(
|
||||
all: &mut BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
|
||||
record: &NumberedRecord,
|
||||
) {
|
||||
for (name, values) in &record.indexes {
|
||||
for value in values {
|
||||
if let Some(numbers) = all.get_mut(name).and_then(|values| values.get_mut(value)) {
|
||||
numbers.remove(&record.record_number);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn validate_indexes(indexes: &BTreeMap<String, Vec<String>>) -> Result<(), String> {
|
||||
if indexes.len() > 32 {
|
||||
return Err("HOLOLAKE_NUMBERED_STORE_INDEX_INVALID".into());
|
||||
}
|
||||
for (name, values) in indexes {
|
||||
validate_index_atom(name)?;
|
||||
if values.len() > 512 {
|
||||
return Err("HOLOLAKE_NUMBERED_STORE_INDEX_INVALID".into());
|
||||
}
|
||||
for value in values {
|
||||
validate_index_atom(value)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn validate_index_atom(value: &str) -> Result<(), String> {
|
||||
if value.trim().is_empty() || value.chars().count() > 200 {
|
||||
return Err("HOLOLAKE_NUMBERED_STORE_INDEX_INVALID".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn validate_number(value: &str) -> Result<(), String> {
|
||||
if value.len() < 3
|
||||
|| value.len() > 160
|
||||
|| !value
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':' | '∞'))
|
||||
{
|
||||
return Err("HOLOLAKE_NUMBERED_STORE_NUMBER_INVALID".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn validate_text(value: &str, max: usize, label: &str) -> Result<(), String> {
|
||||
if value.trim().is_empty() || value.chars().count() > max {
|
||||
return Err(format!("HOLOLAKE_NUMBERED_STORE_{label}_INVALID"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn hash_unsigned(value: &NumberedEventUnsigned) -> Result<String, String> {
|
||||
Ok(sha256(
|
||||
&serde_json::to_vec(value).map_err(|error| error.to_string())?,
|
||||
))
|
||||
}
|
||||
fn sha256(bytes: &[u8]) -> String {
|
||||
digest(&SHA256, bytes)
|
||||
.as_ref()
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect()
|
||||
}
|
||||
fn now_ms() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
fn lock(path: &Path) -> Result<std::fs::File, String> {
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.mode(0o600)
|
||||
.open(path)
|
||||
.map_err(|error| error.to_string())?;
|
||||
file.lock_exclusive().map_err(|error| error.to_string())?;
|
||||
Ok(file)
|
||||
}
|
||||
fn atomic_json<T: Serialize>(path: &Path, value: &T) -> Result<(), String> {
|
||||
atomic_write(
|
||||
path,
|
||||
&serde_json::to_vec(value).map_err(|error| error.to_string())?,
|
||||
)
|
||||
}
|
||||
fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), String> {
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| "HOLOLAKE_NUMBERED_STORE_PATH_INVALID".to_string())?;
|
||||
fs::create_dir_all(parent).map_err(|error| error.to_string())?;
|
||||
let temp = parent.join(format!(".{}.tmp", Uuid::new_v4().simple()));
|
||||
let mut file = OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.mode(0o600)
|
||||
.open(&temp)
|
||||
.map_err(|error| error.to_string())?;
|
||||
file.write_all(bytes)
|
||||
.and_then(|_| file.sync_all())
|
||||
.map_err(|error| error.to_string())?;
|
||||
fs::rename(temp, path).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn put(expected: u64, value: &str) -> NumberedMutation {
|
||||
NumberedMutation {
|
||||
record_number: "EDU-PLAN-000001".into(),
|
||||
record_type: "EDUCATION_PLAN".into(),
|
||||
expected_revision: Some(expected),
|
||||
payload: serde_json::json!({"state":value}),
|
||||
indexes: BTreeMap::from([("state".into(), vec![value.into()])]),
|
||||
tombstone: false,
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn exact_number_revision_index_and_recovery_work() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let collection = "TCS-EDU-DATA-0001";
|
||||
let first = commit_at(
|
||||
root,
|
||||
NumberedCommitInput {
|
||||
collection_number: collection.into(),
|
||||
source: "TEST".into(),
|
||||
authority_receipt: "HUMAN-TEST".into(),
|
||||
mutations: vec![put(0, "DRAFT")],
|
||||
},
|
||||
1,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(first.sequence, 1);
|
||||
assert_eq!(
|
||||
read_at(root, collection, "EDU-PLAN-000001")
|
||||
.unwrap()
|
||||
.revision,
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
query_at(root, collection, "state", "DRAFT").unwrap().len(),
|
||||
1
|
||||
);
|
||||
let collection_path = collection_root(root, collection).unwrap();
|
||||
let stale_snapshot = fs::read(collection_path.join("snapshot.json")).unwrap();
|
||||
assert!(commit_at(
|
||||
root,
|
||||
NumberedCommitInput {
|
||||
collection_number: collection.into(),
|
||||
source: "TEST".into(),
|
||||
authority_receipt: "HUMAN-TEST".into(),
|
||||
mutations: vec![put(0, "ACTIVE")]
|
||||
},
|
||||
2
|
||||
)
|
||||
.is_err());
|
||||
commit_at(
|
||||
root,
|
||||
NumberedCommitInput {
|
||||
collection_number: collection.into(),
|
||||
source: "TEST".into(),
|
||||
authority_receipt: "HUMAN-TEST".into(),
|
||||
mutations: vec![put(1, "ACTIVE")],
|
||||
},
|
||||
2,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(collection_path.join("snapshot.json"), stale_snapshot).unwrap();
|
||||
assert_eq!(
|
||||
read_at(root, collection, "EDU-PLAN-000001")
|
||||
.unwrap()
|
||||
.payload["state"],
|
||||
"ACTIVE"
|
||||
);
|
||||
fs::remove_file(collection_path.join("snapshot.json")).unwrap();
|
||||
assert_eq!(
|
||||
read_at(root, collection, "EDU-PLAN-000001")
|
||||
.unwrap()
|
||||
.payload["state"],
|
||||
"ACTIVE"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn tampering_fails_closed() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let collection = "TCS-EDU-DATA-0001";
|
||||
commit_at(
|
||||
dir.path(),
|
||||
NumberedCommitInput {
|
||||
collection_number: collection.into(),
|
||||
source: "TEST".into(),
|
||||
authority_receipt: "HUMAN-TEST".into(),
|
||||
mutations: vec![put(0, "DRAFT")],
|
||||
},
|
||||
1,
|
||||
)
|
||||
.unwrap();
|
||||
let root = collection_root(dir.path(), collection).unwrap();
|
||||
let event = fs::read_dir(root.join("events"))
|
||||
.unwrap()
|
||||
.next()
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.path();
|
||||
let mut raw = fs::read_to_string(&event).unwrap();
|
||||
raw = raw.replace("DRAFT", "ACTIVE");
|
||||
fs::write(event, raw).unwrap();
|
||||
assert!(read_at(dir.path(), collection, "EDU-PLAN-000001")
|
||||
.unwrap_err()
|
||||
.contains("CHAIN_INVALID"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -8,6 +8,7 @@ mod code_repo_login;
|
|||
mod direct_local_broker;
|
||||
mod direct_local_session;
|
||||
mod dynamic_capability_routing;
|
||||
mod education_broadcast_tower;
|
||||
mod education_translation;
|
||||
mod education_workspace;
|
||||
mod enterprise_work_channel;
|
||||
|
|
@ -16,6 +17,7 @@ mod glp_envelope;
|
|||
mod gls_bootstrap_compiler;
|
||||
mod gls_protocol_kernel;
|
||||
mod gls_protocol_runtime;
|
||||
mod guanghu_numbered_store;
|
||||
mod hldp_tool_forge;
|
||||
mod home_status;
|
||||
mod human_authorization;
|
||||
|
|
|
|||
|
|
@ -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(), 177);
|
||||
assert_eq!(registry.operations.len(), 184);
|
||||
assert!(!registry.runtime.legacy_direct_commands_allowed);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -300,6 +300,25 @@ pub(crate) async fn dispatch(
|
|||
"education_workspace::get_education_workspace_snapshot" => {
|
||||
json(crate::education_workspace::get_education_workspace_snapshot(app).await?)
|
||||
}
|
||||
"education_broadcast_tower::get_snapshot" => {
|
||||
json(crate::education_broadcast_tower::get_snapshot(app).await?)
|
||||
}
|
||||
"education_broadcast_tower::register_participant" => json(
|
||||
crate::education_broadcast_tower::register_participant(app, input(&payload)?).await?,
|
||||
),
|
||||
"education_broadcast_tower::create_learning_plan" => json(
|
||||
crate::education_broadcast_tower::create_learning_plan(app, input(&payload)?).await?,
|
||||
),
|
||||
"education_broadcast_tower::transition_learning_plan" => json(
|
||||
crate::education_broadcast_tower::transition_learning_plan(app, input(&payload)?)
|
||||
.await?,
|
||||
),
|
||||
"education_broadcast_tower::issue_push_ticket" => {
|
||||
json(crate::education_broadcast_tower::issue_push_ticket(app, input(&payload)?).await?)
|
||||
}
|
||||
"education_broadcast_tower::claim_push_ticket" => {
|
||||
json(crate::education_broadcast_tower::claim_push_ticket(app, input(&payload)?).await?)
|
||||
}
|
||||
"education_workspace::create_education_document" => json(
|
||||
crate::education_workspace::create_education_document(app, input(&payload)?).await?,
|
||||
),
|
||||
|
|
@ -534,6 +553,9 @@ pub(crate) async fn dispatch(
|
|||
"knowledge_base::search_knowledge" => {
|
||||
json(crate::knowledge_base::search_knowledge(app, input(&payload)?).await?)
|
||||
}
|
||||
"knowledge_base::compile_knowledge_thought_index" => json(
|
||||
crate::knowledge_base::compile_knowledge_thought_index(app, input(&payload)?).await?,
|
||||
),
|
||||
"knowledge_base::save_knowledge_document" => {
|
||||
json(crate::knowledge_base::save_knowledge_document(app, input(&payload)?).await?)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1416,6 +1416,62 @@ const ROUTES = {
|
|||
"moduleNumber": "HLP-NIPC-MOD-0030",
|
||||
"operationNumber": "HLP-NIPC-OP-0177",
|
||||
"targetNumber": "HLP-NIPC-TGT-0030"
|
||||
},
|
||||
"compile_knowledge_thought_index": {
|
||||
"protocolVersion": "HLP-NIPC-v1",
|
||||
"callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001",
|
||||
"channelNumber": "HLP-NIPC-CH-0002",
|
||||
"moduleNumber": "HLP-NIPC-MOD-0018",
|
||||
"operationNumber": "HLP-NIPC-OP-0178",
|
||||
"targetNumber": "HLP-NIPC-TGT-0018"
|
||||
},
|
||||
"get_education_broadcast_tower": {
|
||||
"protocolVersion": "HLP-NIPC-v1",
|
||||
"callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001",
|
||||
"channelNumber": "HLP-NIPC-CH-0002",
|
||||
"moduleNumber": "HLP-NIPC-MOD-0024",
|
||||
"operationNumber": "HLP-NIPC-OP-0179",
|
||||
"targetNumber": "HLP-NIPC-TGT-0024"
|
||||
},
|
||||
"register_education_participant": {
|
||||
"protocolVersion": "HLP-NIPC-v1",
|
||||
"callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001",
|
||||
"channelNumber": "HLP-NIPC-CH-0002",
|
||||
"moduleNumber": "HLP-NIPC-MOD-0024",
|
||||
"operationNumber": "HLP-NIPC-OP-0180",
|
||||
"targetNumber": "HLP-NIPC-TGT-0024"
|
||||
},
|
||||
"create_education_learning_plan": {
|
||||
"protocolVersion": "HLP-NIPC-v1",
|
||||
"callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001",
|
||||
"channelNumber": "HLP-NIPC-CH-0002",
|
||||
"moduleNumber": "HLP-NIPC-MOD-0024",
|
||||
"operationNumber": "HLP-NIPC-OP-0181",
|
||||
"targetNumber": "HLP-NIPC-TGT-0024"
|
||||
},
|
||||
"transition_education_learning_plan": {
|
||||
"protocolVersion": "HLP-NIPC-v1",
|
||||
"callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001",
|
||||
"channelNumber": "HLP-NIPC-CH-0002",
|
||||
"moduleNumber": "HLP-NIPC-MOD-0024",
|
||||
"operationNumber": "HLP-NIPC-OP-0182",
|
||||
"targetNumber": "HLP-NIPC-TGT-0024"
|
||||
},
|
||||
"issue_education_push_ticket": {
|
||||
"protocolVersion": "HLP-NIPC-v1",
|
||||
"callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001",
|
||||
"channelNumber": "HLP-NIPC-CH-0002",
|
||||
"moduleNumber": "HLP-NIPC-MOD-0024",
|
||||
"operationNumber": "HLP-NIPC-OP-0183",
|
||||
"targetNumber": "HLP-NIPC-TGT-0024"
|
||||
},
|
||||
"claim_education_push_ticket": {
|
||||
"protocolVersion": "HLP-NIPC-v1",
|
||||
"callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001",
|
||||
"channelNumber": "HLP-NIPC-CH-0002",
|
||||
"moduleNumber": "HLP-NIPC-MOD-0024",
|
||||
"operationNumber": "HLP-NIPC-OP-0184",
|
||||
"targetNumber": "HLP-NIPC-TGT-0024"
|
||||
}
|
||||
} as const
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue