refactor(iot): extract iot-contracts crate for cross-boundary types #270
Reference in New Issue
Block a user
No description provided.
Delete Branch "feat/iot-contracts"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Consolidate the data types, NATS bucket names, and KV key formats
that were scattered across the IoT operator, on-device agent, and
harmony's podman module. Each was defined in one place and quoted /
reimplemented in the others, which is exactly the kind of contract
drift the roadmap v0.1 §2 called for consolidating before we start
layering new features on top.
New crate
iot/iot-contracts:IotScore,PodmanV0Score,PodmanService(movedfrom
harmony::modules::podman::score). Pure data, no harmonydeps.
BUCKET_DESIRED_STATE,BUCKET_AGENT_STATUSconstants,desired_state_key(device, deployment),status_key(device).These values used to be hard-coded in five places (agent main.rs,
operator main.rs, operator/deploy/operator.yaml, smoke-a1.sh,
smoke-a3.sh). Tests lock the literals so a flip can't slip.
AgentStatus { device_id, status, timestamp }.Replaces the anonymous
serde_json::json!{}the agent waspublishing, so the operator can deserialize the heartbeat
payload via a shared struct when §12 v0.1 status aggregation
lands.
Consumer updates:
harmony::modules::podman::scorenow holds only theScore<T>/Interpret<T>trait bindings; the pure types arere-exported from iot-contracts. Trait impls can't move because
the trait lives in harmony, so this is the cleanest split.
iot-operator-v0usesBUCKET_DESIRED_STATEanddesired_state_key— the inlinekv_keyfn now delegates sothe existing internal call sites stay untouched.
iot-agent-v0usesBUCKET_DESIRED_STATE,BUCKET_AGENT_STATUS,status_key, andAgentStatusfor the heartbeat publish.No behavior change. Tests:
cargo test -p iot-contractspasses(8/8). Regression:
smoke-a3.shon x86_64 PASSes end-to-end(reboot-reconnect loop included) — wire format is byte-identical
to the pre-refactor serialization.
Next consumers on deck: operator-side status aggregation (§12 v0.1
#3) and journald log streaming (§12 v0.1 #5), both of which need
shared types across the operator/agent boundary and were the
reason this extraction was prioritized.
Consolidate the data types, NATS bucket names, and KV key formats that were scattered across the IoT operator, on-device agent, and harmony's podman module. Each was defined in one place and quoted / reimplemented in the others, which is exactly the kind of contract drift the roadmap v0.1 §2 called for consolidating before we start layering new features on top. New crate `iot/iot-contracts`: * score.rs — `IotScore`, `PodmanV0Score`, `PodmanService` (moved from `harmony::modules::podman::score`). Pure data, no harmony deps. * kv.rs — `BUCKET_DESIRED_STATE`, `BUCKET_AGENT_STATUS` constants, `desired_state_key(device, deployment)`, `status_key(device)`. These values used to be hard-coded in five places (agent main.rs, operator main.rs, operator/deploy/operator.yaml, smoke-a1.sh, smoke-a3.sh). Tests lock the literals so a flip can't slip. * status.rs — typed `AgentStatus { device_id, status, timestamp }`. Replaces the anonymous `serde_json::json!{}` the agent was publishing, so the operator can deserialize the heartbeat payload via a shared struct when §12 v0.1 status aggregation lands. Consumer updates: * `harmony::modules::podman::score` now holds only the `Score<T>` / `Interpret<T>` trait bindings; the pure types are re-exported from iot-contracts. Trait impls can't move because the trait lives in harmony, so this is the cleanest split. * `iot-operator-v0` uses `BUCKET_DESIRED_STATE` and `desired_state_key` — the inline `kv_key` fn now delegates so the existing internal call sites stay untouched. * `iot-agent-v0` uses `BUCKET_DESIRED_STATE`, `BUCKET_AGENT_STATUS`, `status_key`, and `AgentStatus` for the heartbeat publish. No behavior change. Tests: `cargo test -p iot-contracts` passes (8/8). Regression: `smoke-a3.sh` on x86_64 PASSes end-to-end (reboot-reconnect loop included) — wire format is byte-identical to the pre-refactor serialization. Next consumers on deck: operator-side status aggregation (§12 v0.1 #3) and journald log streaming (§12 v0.1 #5), both of which need shared types across the operator/agent boundary and were the reason this extraction was prioritized.This feels very weak overall.
I think we need to take a big step back and revisit what "iot" means. Harmony modules to deploy podman in a loop from definitions received from a nats kv store is not iot specific. It is rather the foundation for our decentralized infrastructure management.
I think we have to revisit the usage of "iot" here. Rasbperry Pi iot is just a concrete use case / implementation detail. The next use case won't necessarily be iot related.
@@ -0,0 +16,4 @@/// A single container managed by podman on the device.#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]pub struct PodmanService {podman is not iot specific. Podman belongs as a first-class citizen in harmony . Containers / containerruntime is a first class abstraction (I think it belongs in domain) and podman is either modules or infra. Definitely does not belong hidden in an iot crate.
Replaces an 8-link `.arg("-t").arg("ed25519").arg("-N")…` chain with a single `.args([...])` of string literals, plus one trailing `.arg()` for the `&PathBuf` (kept separate so we don't force it through the `IntoIterator<Item=&str>` channel). No behavior change.`AgentStatus.device_id` and `AgentStatus.timestamp` were stringly typed. Both now carry real types that prevent a whole class of wire-format typos while keeping the on-wire JSON shape intact. **device_id: String → harmony_types:🆔:Id** Agent config + heartbeat payload now share the same `Id` that the example IoT pipeline already uses for `IotDeviceSetupConfig`. Mixing a device id with a deployment name or arbitrary `String` is now a type error. `Id` is re-exported from `iot-contracts` so consumers don't need a direct `harmony_types` dependency just to name the field. To keep the wire format byte-compatible, `harmony_types::Id` gains `#[serde(transparent)]`. Audit: no consumer in the tree relies on the previous `{"value": "…"}` shape — `Id` is persisted by sqlite via `to_string()`, never serialized directly — so this is a latent-bug fix more than a behavior change. **timestamp: String → chrono::DateTime<Utc>** The agent was calling `chrono::Utc::now().to_rfc3339()` and stuffing the String into the payload. It now holds a real `DateTime<Utc>` which serde-serializes as RFC 3339 anyway. The smoke script's reboot-gate lex comparison still works: time-digit prefixes resolve before the trailing `Z` (chrono default) vs `+00:00` (prior format) difference matters. **Plumbing** - `iot/iot-agent-v0/src/config.rs`: `AgentSection.device_id: Id`. TOML deserializes the bare string thanks to `#[serde(transparent)]`. - `iot/iot-agent-v0/src/main.rs`: `watch_desired_state` and `report_status` take `Id` instead of `String`. - `iot/iot-contracts/Cargo.toml`: adds `harmony_types` path dep and `chrono = { workspace, features = ["serde"] }`. **Verification** - `cargo test -p iot-contracts`: 8/8 passes. New assertions pin the wire format: `"device_id":"pi-01"` (not `{"value":"pi-01"}`) and `"timestamp":"2026-04-21T18:15:42Z"` (RFC 3339). - x86_64 smoke-a3.sh PASSes end-to-end including the reboot- reconnect loop — wire format remains compatible with the existing smoke-script parsing.