4.5 KiB
4.5 KiB
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<T> tenant wrapper
that makes an unscoped storage key a compile error.
Facts (inlined — no spec read needed)
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<T> { pub tenant: TenantId, pub inner: T }
RunIdisUlidso 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
DefaultonTaskIdorWorkflowVersion. Prior implementation hardcoded a"current"version string: it compiled, passed tests, and made every result unattributable. A newtype withoutDefaultrefuses to compile instead. - No
From<RunId> for TaskId.TaskIdhashes 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. StepIdis opaque: never parsed, never ordered, never assumed numeric.
Steps
- Create the ids crate. Dependencies:
uuid,ulid,smol_str,blake3,serde. - Declare the ten newtypes above. Derive
Clone, Copy(where the inner type isCopy),PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize. - Do not derive or hand-write
DefaultforTaskIdorWorkflowVersion. Do not addFrom<RunId> for TaskId. RunId::new()wrapsUlid::new();Orddelegates to the innerUlidso ordering is creation order.- Define
Scoped<T>. Define the storage-key trait (whatever the table API requires) only forScoped<_>andBranchKey— never a blanket impl forT, or the guard disappears. - Add
trybuildas a dev-dependency withtests/compile_fail/:default_task_id.rs(callsTaskId::default()),unscoped_key.rs(opens a table keyed on a bareRunId). Record the expected.stderrfiles.
Acceptance
trybuildcompile-fail suite: an unscoped table key and a defaultedTaskIdboth 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:
- Open one table keyed
Scoped<RunId>. - Write a value under tenant A and a different value under tenant B, using the
same inner
RunId. - Read back each; assert each tenant sees only its own value.
- Scan the raw table and assert exactly two distinct keys exist.
- Create 1000
RunIds 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
.stderrwithTRYBUILD=overwriteafter the guard broke. Assert the expected stderr namesDefaultand 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<T>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 §3, §19 · rust-agentic-task.md