feat: add numbered knowledge and education tower kernels

This commit is contained in:
冰朔 2026-08-21 09:59:05 +08:00
commit 610c688dd9
15 changed files with 2326 additions and 59 deletions

View file

@ -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));
}
}

View file

@ -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"));
}
}

View file

@ -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;

View file

@ -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);
}

View file

@ -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?)
}