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

105 lines
4.5 KiB
Markdown
Raw Normal View History

2026-08-17 23:05:20 -07:00
# 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)
```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<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 test**`tests/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 `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<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](../../../rust-agentic-sys.md) §3, §19 ·
[rust-agentic-task.md](../../../rust-agentic-task.md)