Skip to main content

qualia_client_core/identity_plane/
device.rs

1//! Apparatus (device install) identity — one Qualia install on one machine.
2
3use crate::setup::DeviceContext;
4use serde::{Deserialize, Serialize};
5use std::time::{SystemTime, UNIX_EPOCH};
6
7fn now_unix() -> u64 {
8    SystemTime::now()
9        .duration_since(UNIX_EPOCH)
10        .map(|d| d.as_secs())
11        .unwrap_or(0)
12}
13
14/// What this apparatus can accept for placement (honest flags, not marketing).
15#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
16pub struct DeviceCapabilities {
17    /// Can run local job-queue work on this process.
18    #[serde(default = "default_true")]
19    pub local_jobs: bool,
20    /// May host local inference when a model is loaded.
21    #[serde(default = "default_true")]
22    pub inference: bool,
23    /// Mesh / peer transport keys are present.
24    #[serde(default)]
25    pub mesh_transport: bool,
26}
27
28fn default_true() -> bool {
29    true
30}
31
32/// Full device record (no private keys — node secrets stay in `node_identity.json`).
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
34pub struct DeviceRecord {
35    /// `did:q42:device:{node_identity_pubkey_hex}`.
36    pub device_id: String,
37    /// Person principal this apparatus is bound to.
38    pub person_id: String,
39    /// Ed25519 verifying key hex for the node identity (mesh/signing).
40    pub identity_pubkey_hex: String,
41    /// Human label (optional).
42    #[serde(default)]
43    pub label: String,
44    /// OS hostname if known (informational only — not an identity).
45    #[serde(default)]
46    pub hostname: String,
47    /// Situation of this machine (ownership, fleet, multi-user setting).
48    #[serde(default)]
49    pub device_context: DeviceContext,
50    pub capabilities: DeviceCapabilities,
51    /// HTTP control plane base URL for fleet job delivery (e.g. `http://192.168.1.10:8080`).
52    /// Empty on pure-local installs; set so other apparatus can POST jobs here.
53    #[serde(default)]
54    pub control_base_url: String,
55    /// True only for the install running in this process.
56    pub is_local: bool,
57    pub created_at_unix: u64,
58    pub last_seen_unix: u64,
59}
60
61/// Public view (identical fields today; kept separate for API stability).
62pub type DeviceRecordPublic = DeviceRecord;
63
64impl DeviceRecord {
65    pub fn device_did_from_pubkey_hex(pubkey_hex: &str) -> String {
66        format!("did:q42:device:{}", pubkey_hex.trim().to_ascii_lowercase())
67    }
68
69    pub fn new_local(
70        person_id: impl Into<String>,
71        identity_pubkey_hex: impl Into<String>,
72        device_context: DeviceContext,
73        label: impl Into<String>,
74    ) -> Self {
75        let identity_pubkey_hex = identity_pubkey_hex.into().to_ascii_lowercase();
76        let device_id = Self::device_did_from_pubkey_hex(&identity_pubkey_hex);
77        let now = now_unix();
78        let hostname = hostname_best_effort();
79        Self {
80            device_id,
81            person_id: person_id.into(),
82            identity_pubkey_hex,
83            label: label.into(),
84            hostname,
85            device_context,
86            capabilities: DeviceCapabilities {
87                local_jobs: true,
88                inference: true,
89                mesh_transport: true,
90            },
91            control_base_url: String::new(),
92            is_local: true,
93            created_at_unix: now,
94            last_seen_unix: now,
95        }
96    }
97
98    pub fn touch(&mut self) {
99        self.last_seen_unix = now_unix();
100    }
101
102    pub fn with_control_base_url(mut self, url: impl Into<String>) -> Self {
103        self.control_base_url = url.into().trim().trim_end_matches('/').to_string();
104        self
105    }
106}
107
108fn hostname_best_effort() -> String {
109    std::env::var("COMPUTERNAME")
110        .or_else(|_| std::env::var("HOSTNAME"))
111        .unwrap_or_default()
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[test]
119    fn device_id_is_not_person_id_shape() {
120        let d = DeviceRecord::new_local(
121            "did:q42:person:aabb",
122            "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff",
123            DeviceContext::default(),
124            "desk",
125        );
126        assert!(d.device_id.starts_with("did:q42:device:"));
127        assert!(!d.device_id.starts_with("did:q42:person:"));
128        assert_ne!(d.device_id, d.person_id);
129    }
130}