# T0.1 — Identity newtypes | Field | Value | |---|---| | Phase | P0 — Foundations | | Size | S — under 1 day | | Status | ✅ Done | | Flags | — | | Spec | inlined below | | Blocks | everything | ## Goal Every id in the system as a distinct newtype, plus the `Scoped` tenant wrapper that makes an unscoped storage key a compile error. ## Facts (inlined — no spec read needed) ```rust pub struct TenantId(Uuid); pub struct WorkflowId(SmolStr); // logical workflow, stable across versions pub struct WorkflowVersion(Blake3Hash); // content hash of canonicalized IR (T3.1) pub struct StepId(SmolStr); // author-assigned, stable across versions pub struct TaskId(Blake3Hash); // comparison-group key; hash of task input pub struct RunId(Ulid); // one execution of one workflow pub struct BranchId(u32); // rewind fork pub struct AttemptNo(u32); pub struct Lsn(u64); // per (run, branch) sequence, not global pub struct GroupEpoch(u32); // comparison-group generation pub struct Scoped { pub tenant: TenantId, pub inner: T } ``` - `RunId` is `Ulid` so it sorts lexicographically by creation time. Recent-run scans become a prefix scan instead of a secondary index. - Multi-tenant from commit one. **Every** table key is `Scoped<_>` — not most. One unprefixed table is a cross-tenant read that a customer finds first. - No `Default` on `TaskId` or `WorkflowVersion`. Prior implementation hardcoded a `"current"` version string: it compiled, passed tests, and made every result unattributable. A newtype without `Default` refuses to compile instead. - No `From for TaskId`. `TaskId` hashes the task input **before any workflow touches it**; deriving it from a run id silently makes every run its own group of one, and it cannot be backfilled. - `StepId` is opaque: never parsed, never ordered, never assumed numeric. ## Steps 1. Create the ids crate. Dependencies: `uuid`, `ulid`, `smol_str`, `blake3`, `serde`. 2. Declare the ten newtypes above. Derive `Clone, Copy` (where the inner type is `Copy`), `PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize`. 3. Do **not** derive or hand-write `Default` for `TaskId` or `WorkflowVersion`. Do not add `From for TaskId`. 4. `RunId::new()` wraps `Ulid::new()`; `Ord` delegates to the inner `Ulid` so ordering is creation order. 5. Define `Scoped`. Define the storage-key trait (whatever the table API requires) **only** for `Scoped<_>` and `BranchKey` — never a blanket impl for `T`, or the guard disappears. 6. Add `trybuild` as a dev-dependency with `tests/compile_fail/`: `default_task_id.rs` (calls `TaskId::default()`), `unscoped_key.rs` (opens a table keyed on a bare `RunId`). Record the expected `.stderr` files. ## Acceptance - `trybuild` compile-fail suite: an unscoped table key and a defaulted `TaskId` both fail to compile. - Run the suite before writing the impls and watch it fail for the right reason (missing type, not missing test file). ## Verify **Harness:** `trybuild` for the compile-fail cases; a scratch `redb` file for the key round trip. **Integration test** — `tests/it_scoped_keys.rs`: 1. Open one table keyed `Scoped`. 2. Write a value under tenant A and a different value under tenant B, using the **same** inner `RunId`. 3. Read back each; assert each tenant sees only its own value. 4. Scan the raw table and assert exactly two distinct keys exist. 5. Create 1000 `RunId`s across at least 3 distinct milliseconds; assert sorted order equals creation order. **Command:** `cargo test -p ids && cargo test -p ids --test compile_fail` **False pass:** - Regenerating `.stderr` with `TRYBUILD=overwrite` after the guard broke. Assert the expected stderr **names `Default` and the key trait** — a test that only checks "does not compile" passes on a typo. - ULID ordering asserted over ids minted inside one millisecond, where ordering comes from the random suffix rather than time. Force distinct milliseconds. - Step 3 passing because the read path filters by tenant. Step 4 (raw scan) is what proves the key, not the query. ## Traps - Blanket `#[derive(Default)]` on the whole module out of habit. - `Scoped` as a type alias instead of a struct — an alias produces no compile error, so the guard is decorative. --- Background (not required to do this task): [rust-agentic-sys.md](../../../rust-agentic-sys.md) §3, §19 · [rust-agentic-task.md](../../../rust-agentic-task.md)