Build: control-core — Ids and the Domain
Maps to: Phase 0 (control-core). Kind: Build.
Objective
Stand up control-core, the crate that holds every type the rest of the control plane speaks, and fill in its two most foundational files: ids.rs (the RunId/JobId newtypes) and domain.rs (the status enums, the accounting structs, and the Run/Job aggregates). Prove with tests that ids are unique, serialize as bare UUID strings, and print as their inner UUID; that statuses serialize snake_case; that a JobOutcome totals its usage; and that a Job round-trips through JSON.
Scaffold
Create:
crates/control-core/Cargo.toml— the crate manifest.[dependencies]:serde(withderive),uuid(withv4,serde),chrono(withserde).[dev-dependencies]:serde_json(the round-trip and serialization tests encode to JSON),pretty_assertions(readableassert_eq!diffs).crates/control-core/src/lib.rs— module declarations + re-exports (pub mod ids; pub mod domain;andpub usethe types you want callers to reach without the module path).crates/control-core/src/ids.rs—RunId,JobId, and their tests.crates/control-core/src/domain.rs— the domain types and their tests.
Why these deps: uuid gives you the 128-bit id and its v4 random constructor; the serde feature lets a Uuid serialize on its own so your transparent newtype has something to delegate to. chrono supplies DateTime<Utc> for Run::created_at, and its serde feature makes that field serialize as an RFC 3339 string.
Expected result: cargo test -p control-core → 6 tests pass:
- from
ids.rs:ids_are_unique,run_id_serializes_as_bare_uuid_string,display_matches_inner_uuid - from
domain.rs:total_usage_sums_every_record,statuses_serialize_snake_case,job_roundtrips_through_json
The spec (givens)
ids.rs — the id newtypes. Two tuple structs, each wrapping a uuid::Uuid:
RunId(pub Uuid)andJobId(pub Uuid).- Derive stack on both:
Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize. - Attribute on both:
#[serde(transparent)]— the wire form is the bare quoted UUID, no wrapper. - Each gets an inherent
new() -> Selfreturning a freshUuid::new_v4(), aDefaultimpl that callsnew(), and aDisplayimpl that writes the inner UUID (write!(f, "{}", self.0)).
[→ Answer key](../appendix-answer-key.md#core-ids)
domain.rs — the vocabulary.
RunStatus: variantsQueued, Running, Done, Failed.JobStatus: variantsPending, Assigned, Done, Failed. Both deriveDebug, Clone, Copy, PartialEq, Eq, Serialize, Deserializeand carry#[serde(rename_all = "snake_case")].Usage: fieldsinput_tokens: u32,output_tokens: u32. DerivesDebug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize(theDefaultis what lets a fold start from zero).Vignette: fieldsid: String,prompt: String. DerivesDebug, Clone, PartialEq, Eq, Serialize, Deserialize.EvalJob: fieldsvignettes: Vec<Vignette>,model: String,epoch: u32. Same derive stack asVignette(noCopy— it owns aVec).ResponseRecord: fieldsvignette_id: String,model: String,epoch: u32,prompt: String,response: String,usage: Usage. Same derive stack.Job: fieldsid: JobId,run_id: RunId,status: JobStatus,attempt: u32,spec: EvalJob. Same derive stack.JobOutcome: fieldsjob_id: JobId,records: Vec<ResponseRecord>. Same derive stack. Plus an inherent methodtotal_usage(&self) -> Usagewhose rule is: suminput_tokensandoutput_tokensacross every record, independently, starting fromUsage::default().Run: fieldsid: RunId,status: RunStatus,created_at: DateTime<Utc>,manifest: String,models: Vec<String>,epochs: u32,job_count: u32,done_count: u32. Same derive stack as the other aggregates.
[→ Answer key](../appendix-answer-key.md#core-domain)
Concepts exercised
- Newtype-over-
Uuidfor id safety;#[serde(transparent)]for bare-value serialization. - A hand-written
Displaythat delegates to an inner field. #[serde(rename_all = "snake_case")]on unit-variant enums.- Folding a
Copy,Defaultaccounting struct across aVec. - Which aggregates can derive
Copy(all fieldsCopy) and which cannot (owns aVecorString).
The build loop (you drive)
Write each test first, predict its failure mode, run, then implement the minimum to green.
ids_are_unique— assertsRunId::new() != RunId::new()and the same forJobId. Predict: does this fail to compile (the type does not exist yet) or fail an assertion? Run, confirm it is the former, then define the newtypes withnew().run_id_serializes_as_bare_uuid_string— build aRunId,serde_json::to_stringit, and assert the JSON equalsformat!("\"{}\"", id.0)(a bare quoted UUID, no[...]or{...}); then deserialize back and assert equality. This is the test that pins the wire contract — add#[serde(transparent)]to pass it and watch what removing the attribute does.display_matches_inner_uuid— assertid.to_string() == id.0.to_string(). Add theDisplayimpl.statuses_serialize_snake_case— assertserde_json::to_string(&RunStatus::Queued)is"\"queued\""and&JobStatus::Assignedis"\"assigned\"". Add the enums withrename_all.total_usage_sums_every_record— build aJobOutcomewith two records (say100/20and50/10tokens) and asserttotal_usage()equalsUsage { input_tokens: 150, output_tokens: 30 }. Implement the fold.job_roundtrips_through_json— build a fullJob(with a nestedEvalJoband aVignette), encode to JSON, decode back, assert equal. This exercises every derive at once.
run_id_serializes_as_bare_uuid_string: with #[serde(transparent)] removed, what would serde_json::to_string(&RunId::new()) produce instead of a bare string — and would the assertion fail at the encode step or at the assert_eq!? Name it, then delete the attribute once and check.
Copy is safe on RunId, JobId, Usage, and the status enums because every field is itself Copy. It is not available on Vignette, EvalJob, ResponseRecord, Job, JobOutcome, or Run — they own a String or a Vec, which are moved, not copied. Deriving Copy on those will not compile, and the compiler will tell you exactly why.
Done when
cargo test -p control-core shows the six tests green, run_id_serializes_as_bare_uuid_string passes with #[serde(transparent)] present (and you have seen it fail with the attribute removed), and you can say which of the domain types derive Copy and why the rest cannot. Commit.