Files
poimen/tasks/T0.1-identity-newtypes.md
T

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 }
  • 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<RunId> 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<RunId> for TaskId.
  4. RunId::new() wraps Ulid::new(); Ord delegates to the inner Ulid so ordering is creation order.
  5. Define Scoped<T>. 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 testtests/it_scoped_keys.rs:

  1. Open one table keyed Scoped<RunId>.
  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 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 .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<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