identifier: std/identifier with Uuid and Ulid #122

Closed
opened 2026-10-05 21:07:23 +00:00 by orion · 5 comments
Collaborator

Context

Identifier, Uuid, Ulid, IdentifierError, the urn:uuid: bridge.

Source

Acceptance criteria

  • As URI.md slice 11 specifies

Dependencies

  • Depends on #3 (random: std/random (RANDOM slice 1))

Moved from the planning lists of the docs; the design record stays the specification.

## Context `Identifier`, `Uuid`, `Ulid`, `IdentifierError`, the `urn:uuid:` bridge. ## Source - [URI.md slice 11](https://git.torb.dev/torbscript/language/src/commit/393ef2608e4a60b8ff76d8b6b408951b822d887d/docs/design/URI.md#L2002) - [FIRST-PARTY.md gap 13](https://git.torb.dev/torbscript/language/src/commit/393ef2608e4a60b8ff76d8b6b408951b822d887d/docs/design/FIRST-PARTY.md#L273) ## Acceptance criteria - [ ] As URI.md slice 11 specifies ## Dependencies - Depends on #3 (random: `std/random` (RANDOM slice 1)) _Moved from the planning lists of the docs; the design record stays the specification._
Author
Collaborator

[loop:torbscript] intake: in scope (author orion, label loop), source URI.md slice 11 and FIRST-PARTY.md gap 13, not a duplicate. Depends on #3. next: waiting (dependency #3) until 2026-10-10T04:00:00Z

[loop:torbscript] intake: in scope (author orion, label loop), source URI.md slice 11 and FIRST-PARTY.md gap 13, not a duplicate. Depends on #3. next: waiting (dependency #3) until 2026-10-10T04:00:00Z
Author
Collaborator

[loop:torbscript] claim refine by session:loop-torbscript-20261023 until 2026-10-23T13:07Z

[loop:torbscript] claim refine by session:loop-torbscript-20261023 until 2026-10-23T13:07Z
Author
Collaborator

[loop:torbscript] decided by the loop: three technical points where URI.md section 12 does not match the code on main (checked against std/time, std/random, std/uri at 9095875), plus the draw layout. All are internal design choices (no owner category), recorded here and to be written into URI.md section 12 by the implementing PR.

  1. at: takes a Timestamp, not an Instant. Section 12 writes Uuid.version7(var source: Random, at: Instant), Ulid.at(): Instant, Ulid.generated(..., at: Instant, ...) and Instant.epoch. Instant is the monotonic reading, "from the origin the process chooses" (std/time), and has no epoch and no Unix reading, so a UUIDv7 or ULID millisecond cannot be made from it. Timestamp is the wall clock in nanoseconds since 1970-01-01 UTC (Timestamp.fromUnixNanoseconds, unixNanoseconds()), is a prelude value that reads nothing, and keeps the section's point: the clock is an argument, std/identifier has no capability. The example becomes Uuid.version7(source, at: Timestamp.fromUnixNanoseconds 0). Why: the only other way is to add an epoch to Instant, which would contradict its documented meaning and widen std/time for no gain.
  2. Time before 1970 panics. The 48-bit millisecond field is unsigned, and Timestamp (1677 to 2262) is the only source of a negative value. version7 and generated panic with a message naming the argument for a Timestamp before the epoch, like the Int64 arithmetic in std/time; they keep their plain Uuid/Ulid result. Why: a Result for an input no clock produces would make every call site pay for a case that does not occur. The same holds for Ulid.generated when after is in the same millisecond and its 80-bit random part is all ones (only reachable with a hand-built value): it panics instead of wrapping, which the ULID specification calls an error.
  3. The bit layout is frozen, like Random's. version4 takes two uint64() draws: the first fills bytes 0 to 7, the second bytes 8 to 15 (most significant first), then the version nibble (4) and the variant bits (10) are set. version7 writes the 48-bit millisecond count in bytes 0 to 5, then draws two uint64() values: the top 12 bits of the first are rand_a (after the version nibble 7), the top 62 bits of the second are rand_b (after the variant bits 10). Ulid.generated writes the 48-bit millisecond count, then draws two uint64() values and uses 80 of the 128 bits for the random part (top 16 bits of the first, all 64 of the second). Why: Random promises the same numbers per seed on every toolchain, and an identifier made from a seeded source is only reproducible (golden files, tests) if the layout is as fixed as the generator. The known-answer tests below pin it.

Not decided here because it needs no decision: Uuid keeps exactly one conversion pair (String), since Urn has no From<Uuid>; the Decode rule of compiler/src/semantics/checker/derive.trb (conversionPairOf) therefore still derives it, and a test covers the JSON round trip.

[loop:torbscript] decided by the loop: three technical points where URI.md section 12 does not match the code on `main` (checked against `std/time`, `std/random`, `std/uri` at 9095875), plus the draw layout. All are internal design choices (no owner category), recorded here and to be written into URI.md section 12 by the implementing PR. 1. **`at:` takes a `Timestamp`, not an `Instant`.** Section 12 writes `Uuid.version7(var source: Random, at: Instant)`, `Ulid.at(): Instant`, `Ulid.generated(..., at: Instant, ...)` and `Instant.epoch`. `Instant` is the monotonic reading, "from the origin the process chooses" (`std/time`), and has no epoch and no Unix reading, so a UUIDv7 or ULID millisecond cannot be made from it. `Timestamp` is the wall clock in nanoseconds since 1970-01-01 UTC (`Timestamp.fromUnixNanoseconds`, `unixNanoseconds()`), is a prelude value that reads nothing, and keeps the section's point: the clock is an argument, `std/identifier` has no capability. The example becomes `Uuid.version7(source, at: Timestamp.fromUnixNanoseconds 0)`. Why: the only other way is to add an epoch to `Instant`, which would contradict its documented meaning and widen `std/time` for no gain. 2. **Time before 1970 panics.** The 48-bit millisecond field is unsigned, and `Timestamp` (1677 to 2262) is the only source of a negative value. `version7` and `generated` panic with a message naming the argument for a `Timestamp` before the epoch, like the `Int64` arithmetic in `std/time`; they keep their plain `Uuid`/`Ulid` result. Why: a `Result` for an input no clock produces would make every call site pay for a case that does not occur. The same holds for `Ulid.generated` when `after` is in the same millisecond and its 80-bit random part is all ones (only reachable with a hand-built value): it panics instead of wrapping, which the ULID specification calls an error. 3. **The bit layout is frozen, like `Random`'s.** `version4` takes two `uint64()` draws: the first fills bytes 0 to 7, the second bytes 8 to 15 (most significant first), then the version nibble (`4`) and the variant bits (`10`) are set. `version7` writes the 48-bit millisecond count in bytes 0 to 5, then draws two `uint64()` values: the top 12 bits of the first are `rand_a` (after the version nibble `7`), the top 62 bits of the second are `rand_b` (after the variant bits `10`). `Ulid.generated` writes the 48-bit millisecond count, then draws two `uint64()` values and uses 80 of the 128 bits for the random part (top 16 bits of the first, all 64 of the second). Why: `Random` promises the same numbers per seed on every toolchain, and an identifier made from a seeded source is only reproducible (golden files, tests) if the layout is as fixed as the generator. The known-answer tests below pin it. Not decided here because it needs no decision: `Uuid` keeps exactly one conversion pair (`String`), since `Urn` has no `From<Uuid>`; the `Decode` rule of `compiler/src/semantics/checker/derive.trb` (`conversionPairOf`) therefore still derives it, and a test covers the JSON round trip.
Author
Collaborator

[loop:torbscript] refine: dependency #3 is closed and landed on main (std/random, PR 159, commit 9e2ab91; std/random exists with Random.uint64(), seeded, split, stable SplitMix64 vectors). #122 is brought to the definition of ready; the decisions are in the comment above. The criteria below replace the body's "as URI.md slice 11 specifies", next: ready

Goal

std/identifier: the trait Identifier, the types Uuid (RFC 9562, versions 4 and 7) and Ulid, IdentifierError, and the urn:uuid: bridge to std/uri. A pure package: randomness and clock come in as arguments, no capability, no native.

Implements

  • URI.md section 12 and slice 11 (owner answers to taste questions 3 and 4 are in section 16: Identifier, UUIDv7 as default, Ulid kept), with the corrections in the decision comment (Timestamp for Instant, panic before 1970, frozen layout).
  • FIRST-PARTY.md gap 13 (a UUID type; it names "URI.md slice 6", which is stale: slice 11).
  • ROADMAP row std/identifier.

Seed impact

None. Pure TorbScript in a new std/identifier package (use ... from "std/random", "std/uri", "std/time"/prelude Timestamp): no syntax change, no new native, no driver flag, no std name the compiler looks up by string. No two-commit seed rule applies. torb/sql's own Uuid (a separate repository) is not touched.

Acceptance criteria

  1. std/identifier exists (project.trb with name = "std/identifier", src/lib.trb plus modules, tests/) and is a workspace member through std/*; it is not in the prelude.
  2. public trait Identifier with Show, Equals, Hash, Compare, TryFrom<String, IdentifierError> with fn bytes(): List<UInt8> (sixteen bytes, most significant first); one fixed IdentifierError { text: String, expected: String } with Show, Error.
  3. Uuid: version(): Int; static fn version4(var source: Random): Uuid; static fn version7(var source: Random, at: Timestamp): Uuid; text is the canonical 36 lower-case characters; Uuid.tryFrom reads upper and lower case, refuses a wrong length, a misplaced dash and a non-hex character with IdentifierError(text, "a UUID"). show() and String.from(uuid) give the canonical text.
  4. Ulid: at(): Timestamp (whole milliseconds); static fn generated(var source: Random, at: Timestamp, after: Ulid? = None): Ulid (same millisecond as after: the random part is after's incremented by one, otherwise drawn); text is 26 characters of Crockford base 32, upper case; tryFrom reads either case, refuses a wrong length, characters outside the alphabet and a first character above 7 (it would overflow 128 bits) with IdentifierError(text, "a ULID").
  5. Bridge: extend Uri with From<Uuid> gives urn:uuid:<canonical text>; extend Uuid with TryFrom<Urn, IdentifierError> accepts namespace uuid with a valid UUID (case-insensitive, as RFC 8141 makes the namespace case-insensitive) and refuses any other namespace or text. Uuid has exactly one conversion pair (String), checked by a JSON encode/decode round trip through the 36-character text.
  6. Ordering and hashing: Compare is bytewise (so UUIDv7 and ULID of increasing milliseconds compare increasing, and the Ulid text order equals the Compare order); Equals/Hash agree, so a Uuid works as a Map key.
  7. Known-answer tests (std/identifier/tests/*.test.trb, run by torb test in the VM; conformance in both back ends only if a program under tests/conformance is added): version4 and version7 from Random.seeded 0 and Random.seeded 1234567 against fixed expected texts computed from the layout in the decision comment and the published SplitMix64 vectors; the RFC 9562 text examples parse and print unchanged; version7(source, at: Timestamp.fromUnixNanoseconds 0) starts with 00000000-0000-7; the version() nibble and the variant bits 10 on 1,000 generated values; the ULID specification's example 01ARZ3NDEKTSV4RRFFQ69G5FAV parses and prints unchanged and at() of it is its millisecond; monotonic generated with after increments by exactly one in the same millisecond and draws afresh in the next; a Timestamp before the epoch panics (test for a panic if std/test has one, otherwise documented).
  8. Docs in the same PR: URI.md section 12 and slice 11 (state done, the Timestamp correction, the frozen layout), FIRST-PARTY.md gap 13 marked done with the corrected slice number, ROADMAP row, new docs/standard-library/identifier.md plus its entry in docs/standard-library/index.md, and the regenerated skills/ (torb docs skill docs skills, --check is a tier A gate).
  9. CI: forge CI sh tools/gates.sh a green for the PR (torb format --check, docs-skill check, pdisc, check-commits); b is not required (no compiler/src/ir, compiler/src/backend or runtime/ change).

Size

One PR, two Conventional Commits: feat(identifier): add std/identifier with Uuid, Ulid and the urn bridge (package, tests) and docs(identifier): document std/identifier, mark URI slice 11 and FIRST-PARTY gap 13 done (or one commit if the generated skills/ stay with the package). Implementer: sonnet/medium (std/ and docs). Risk to watch, not a blocker: extend Uri with From<Uuid> from another package (the same shape as extend String with From<Uri>, but across packages); if the checker rejects it, fix at the checker with a note in the PR rather than moving the bridge into std/uri (URI.md section 7 keeps std/uri free of std/identifier).

Worker: RE sonnet/medium, about 90k tokens (estimate; not readable from inside the run).

[loop:torbscript] refine: dependency #3 is closed and landed on `main` (`std/random`, PR 159, commit 9e2ab91; `std/random` exists with `Random.uint64()`, `seeded`, `split`, stable SplitMix64 vectors). #122 is brought to the definition of ready; the decisions are in the comment above. The criteria below replace the body's "as URI.md slice 11 specifies", next: ready ## Goal `std/identifier`: the trait `Identifier`, the types `Uuid` (RFC 9562, versions 4 and 7) and `Ulid`, `IdentifierError`, and the `urn:uuid:` bridge to `std/uri`. A pure package: randomness and clock come in as arguments, no capability, no native. ## Implements - [URI.md](https://git.torb.dev/torbscript/language/src/branch/main/docs/design/URI.md) section 12 and slice 11 (owner answers to taste questions 3 and 4 are in section 16: `Identifier`, UUIDv7 as default, `Ulid` kept), with the corrections in the decision comment (`Timestamp` for `Instant`, panic before 1970, frozen layout). - [FIRST-PARTY.md](https://git.torb.dev/torbscript/language/src/branch/main/docs/design/FIRST-PARTY.md) gap 13 (a UUID type; it names "URI.md slice 6", which is stale: slice 11). - ROADMAP row `std/identifier`. ## Seed impact None. Pure TorbScript in a new `std/identifier` package (`use ... from "std/random"`, `"std/uri"`, `"std/time"`/prelude `Timestamp`): no syntax change, no new native, no driver flag, no std name the compiler looks up by string. No two-commit seed rule applies. `torb/sql`'s own `Uuid` (a separate repository) is not touched. ## Acceptance criteria 1. `std/identifier` exists (`project.trb` with `name = "std/identifier"`, `src/lib.trb` plus modules, `tests/`) and is a workspace member through `std/*`; it is not in the prelude. 2. `public trait Identifier with Show, Equals, Hash, Compare, TryFrom<String, IdentifierError>` with `fn bytes(): List<UInt8>` (sixteen bytes, most significant first); one fixed `IdentifierError { text: String, expected: String }` with `Show, Error`. 3. `Uuid`: `version(): Int`; `static fn version4(var source: Random): Uuid`; `static fn version7(var source: Random, at: Timestamp): Uuid`; text is the canonical 36 lower-case characters; `Uuid.tryFrom` reads upper and lower case, refuses a wrong length, a misplaced dash and a non-hex character with `IdentifierError(text, "a UUID")`. `show()` and `String.from(uuid)` give the canonical text. 4. `Ulid`: `at(): Timestamp` (whole milliseconds); `static fn generated(var source: Random, at: Timestamp, after: Ulid? = None): Ulid` (same millisecond as `after`: the random part is `after`'s incremented by one, otherwise drawn); text is 26 characters of Crockford base 32, upper case; `tryFrom` reads either case, refuses a wrong length, characters outside the alphabet and a first character above `7` (it would overflow 128 bits) with `IdentifierError(text, "a ULID")`. 5. Bridge: `extend Uri with From<Uuid>` gives `urn:uuid:<canonical text>`; `extend Uuid with TryFrom<Urn, IdentifierError>` accepts namespace `uuid` with a valid UUID (case-insensitive, as RFC 8141 makes the namespace case-insensitive) and refuses any other namespace or text. `Uuid` has exactly one conversion pair (`String`), checked by a JSON `encode`/`decode` round trip through the 36-character text. 6. Ordering and hashing: `Compare` is bytewise (so UUIDv7 and ULID of increasing milliseconds compare increasing, and the `Ulid` text order equals the `Compare` order); `Equals`/`Hash` agree, so a `Uuid` works as a `Map` key. 7. Known-answer tests (`std/identifier/tests/*.test.trb`, run by `torb test` in the VM; conformance in both back ends only if a program under `tests/conformance` is added): `version4` and `version7` from `Random.seeded 0` and `Random.seeded 1234567` against fixed expected texts computed from the layout in the decision comment and the published SplitMix64 vectors; the RFC 9562 text examples parse and print unchanged; `version7(source, at: Timestamp.fromUnixNanoseconds 0)` starts with `00000000-0000-7`; the `version()` nibble and the variant bits `10` on 1,000 generated values; the ULID specification's example `01ARZ3NDEKTSV4RRFFQ69G5FAV` parses and prints unchanged and `at()` of it is its millisecond; monotonic `generated` with `after` increments by exactly one in the same millisecond and draws afresh in the next; a `Timestamp` before the epoch panics (`test` for a panic if `std/test` has one, otherwise documented). 8. Docs in the same PR: URI.md section 12 and slice 11 (state `done`, the `Timestamp` correction, the frozen layout), FIRST-PARTY.md gap 13 marked done with the corrected slice number, ROADMAP row, new `docs/standard-library/identifier.md` plus its entry in `docs/standard-library/index.md`, and the regenerated `skills/` (`torb docs skill docs skills`, `--check` is a tier A gate). 9. CI: forge CI `sh tools/gates.sh a` green for the PR (`torb format --check`, docs-skill check, pdisc, check-commits); `b` is not required (no `compiler/src/ir`, `compiler/src/backend` or `runtime/` change). ## Size One PR, two Conventional Commits: `feat(identifier): add std/identifier with Uuid, Ulid and the urn bridge` (package, tests) and `docs(identifier): document std/identifier, mark URI slice 11 and FIRST-PARTY gap 13 done` (or one commit if the generated `skills/` stay with the package). Implementer: sonnet/medium (`std/` and docs). Risk to watch, not a blocker: `extend Uri with From<Uuid>` from another package (the same shape as `extend String with From<Uri>`, but across packages); if the checker rejects it, fix at the checker with a note in the PR rather than moving the bridge into `std/uri` (URI.md section 7 keeps `std/uri` free of `std/identifier`). Worker: RE sonnet/medium, about 90k tokens (estimate; not readable from inside the run).
Author
Collaborator

[loop:torbscript] claim implement by session:loop-torbscript-20261023 until 2026-10-23T17:18Z

[loop:torbscript] claim implement by session:loop-torbscript-20261023 until 2026-10-23T17:18Z
argus closed this issue 2026-10-23 12:02:59 +00:00
Sign in to join this conversation.
No labels
loop
ready
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
torbscript/language#122
No description provided.