Skip to content

Unify local application identity tools - #16

Merged
raghubetina merged 3 commits into
mainfrom
codex/stream1-generators-init
Aug 4, 2026
Merged

Unify local application identity tools#16
raghubetina merged 3 commits into
mainfrom
codex/stream1-generators-init

Conversation

@raghubetina

Copy link
Copy Markdown
Contributor

Stack

CLI stack 1 of 2. The follow-up product Compile and retained-Compilation commands will target this branch.

Summary

  • replace Plan-specific subject ID generation with general UUIDv7 generation, including small batches
  • derive schema-safe application keys from arbitrary interoperable Unicode names
  • let plan init derive either a missing name or a missing application key
  • preserve explicit Foundation Plan keys without compatibility aliases or silent rewriting
  • remove plan subject-id completely and update help, README, packaging checks, and packed smoke coverage

Derived keys are deterministic, normalization-stable, bounded for the current iOS identifier component, and use readable prefixes with digest fallbacks where needed.

Verification

  • PATH="/Users/sandbox2/.asdf/shims:$PATH" npm run check
    • typecheck
    • ESLint
    • Prettier
    • 149 tests
    • package allowlist
    • freshly packed executable smoke

No package was published and no compatibility alias was added.

Move UUID creation into a general generate group so agents can request batches without Plan state. Let initialization derive either identity value while preserving schema-valid explicit keys.
@raghubetina

Copy link
Copy Markdown
Contributor Author

The Node 24 quality failure is the newly published transitive brace-expansion advisory, not this feature diff. I isolated the 5.0.9 lockfile repair in #17. The three Node 22 platform jobs are green; after #17 lands, this branch should be updated from main and CI rerun.

@raghubetina

Copy link
Copy Markdown
Contributor Author

Stack note update: this PR was originally described as 1 of 2. The feature stack now has three chunks: #16 for generators and flexible initialization, #18 for product Compile UX, and #19 for retained Compilation status and download. The independent Node advisory repair in #17 should land before this stack. No code in this PR is made obsolete by the later chunks.

@raghubetina

Copy link
Copy Markdown
Contributor Author

Review

No defects. The derivation holds up against adversarial Unicode, and the two properties most likely to be subtly wrong, normalization stability and the byte ceiling, both check out empirically.

Verification

npm run check: typecheck, ESLint, Prettier, 149 tests / 149 pass, package allowlist, and the packed executable smoke. Matches the description exactly.

Command surface behaves as claimed:

$ firstdraft plan subject-id
Unknown command.
Run 'firstdraft plan --help' for usage.          rc=2

$ firstdraft generate uuid
019fce70-b30c-7619-b42e-79906bf0dd12             rc=0

$ firstdraft generate application-key --name "Oscar Party"
oscar_party                                      rc=0

The removed command fails cleanly with a pointer to help rather than doing something surprising, and the emitted UUID has the version 7 nibble and the correct variant bits.

Property testing the derivation

I ran deriveApplicationKey over 17 adversarial names plus three normalization pairs, checking four invariants per input: matches ^[a-z][a-z0-9_]*$, at most 63 bytes, deterministic across repeated calls, and stable across Unicode normalization forms.

All held. The interesting cases:

Input Key Bytes
"Æther Øre Łódź Þing ß" aether_ore_lodz_thing_ss 24
"fi ligature" fi_ligature 11
"Ⅻ roman" xii_roman 9
"123 Numbers First" app_123_numbers_first 21
"🎬🎥 Movie Night" movie_night 11
"日本語のアプリ" app_af6a6b6ae86e 16
"​zero width" zero_width 10
200 a characters aaa…aaa_c2a908d98f5d 63

The long case lands on exactly 63 bytes rather than near it, so the prefix arithmetic is right at the boundary rather than approximately right.

Normalization pairs all converged:

"Café Club" (NFC) -> cafe_club   |  "Café Club" (NFD) -> cafe_club
"Ångström"  (NFC) -> angstrom    |  "Ångström"  (NFD) -> angstrom
"Ólafur Þór"(NFC) -> olafur_thor |  "Ólafur Þór"(NFD) -> olafur_thor

Two details that are doing real work

Normalizing before digesting, not after.

const normalizedName = name.normalize("NFKD");
const readable = asciiWords(normalizedName);
const candidate = readable.length === 0
  ? `app_${stableDigest(normalizedName)}`
  : ...

stableDigest receives normalizedName, not name. That is what extends normalization stability to the digest fallback. Had it hashed the raw input, "日本語" typed on macOS and on Linux could produce different keys while the readable path stayed stable, which is the kind of inconsistency that shows up only once two contributors compare output.

The explicit transliteration map.

const LATIN_TRANSLITERATIONS = new Map([
  ["Æ", "ae"], ["Ø", "o"], ["Ł", "l"], ["Þ", "th"], ["ß", "ss"], ...
]);

NFKD decomposes é into e plus a combining mark, so stripping marks leaves e. It does not decompose Æ, Ø, Ł, Þ, or ß at all, because those are not precomposed forms of anything. Without the map they would be dropped and "Ólafur Þór" would become olafur_or. The probe confirms thor, so the map is reached.

Worth a comment on that constant explaining why it exists, since it looks like an arbitrary list and it is actually "the characters NFKD will not help with."

One note: derivation is deliberately lossy

Six distinct names collapse to one key:

movie_night <- "Movie Night"
movie_night <- "🎬 Movie Night"
movie_night <- "Movie  Night"
movie_night <- "movie night"
movie_night <- "MOVIE-NIGHT"
movie_night <- "Movie_Night!"

Same for "Café", "Cafe", and "CAFÉ" all giving cafe.

I checked whether that matters and it does not, today. On the service, application_key is NOT NULL with a format check constraint and no unique index and no uniqueness validation:

t.string "application_key", null: false
t.check_constraint "application_key::text ~ '^[a-z][a-z0-9_]*$'"
validates :application_key, presence: true, format: {with: /\A[a-z][a-z0-9_]*\z/}

So two Projects may share a key and the collisions are harmless. Raising it only because the derivation being non-injective is a fact somebody will need if uniqueness is ever added. A unique index on application_key would make "create a second project called Movie Nights" fail for a reason the user cannot see, and the fix at that point is to stop deriving rather than to change the derivation.

The removal

plan subject-id is gone with no alias, which the description states plainly. Fair for a 0.1.0-alpha.2 package, and the replacement is more general: generate uuid covers the same need without being Plan-specific, and supports batches.

The one thing worth confirming outside this diff is whether any published skill or documentation still instructs an agent to run plan subject-id. The firstdraft/skills package ships instructions that drive this CLI, and a removed command referenced there would fail at the point an agent tries it. I will check that against skills#17 and skills#18 when I reach them and follow up if anything still names it.

@raghubetina

Copy link
Copy Markdown
Contributor Author

Two strings that look identical and are not

This PR turns a name a human typed into an identifier a computer can use. "Oscar Party" becomes oscar_party. Straightforward until you remember that a name can be any text at all, and text is much stranger than it looks.

Here is the fact that makes it strange. Open a Node console:

"café" === "café"   // false

Those are not a typo. They are two different strings that render identically and, to any reader, mean the same word.

Why

There are two ways to write é.

Precomposed: one code point, U+00E9, LATIN SMALL LETTER E WITH ACUTE.

Decomposed: two code points, e (U+0065) followed by U+0301, COMBINING ACUTE ACCENT. Your text renderer draws the accent on top of the e.

"café".length   // 4
"café".length   // 5

Same glyphs on screen, different data in memory. And which one you get depends on where the text came from. macOS filesystems historically hand you decomposed forms. Most Linux tooling and most web input produce precomposed. Copy a name out of a PDF and you get whichever the PDF had.

So a user on a Mac and a user on Linux can type what they both experience as the same name and hand your program two different strings.

Why that would break this

Without care, the derivation produces two different keys for the same name. One user's project is cafe_club and another's is something else, and now a deterministic derivation is not deterministic across machines. Worse, it fails intermittently and only for people whose names contain accents, which is a bug report you will not enjoy.

The fix is one call:

const normalizedName = name.normalize("NFKD");

Unicode defines normalization forms, and normalizing both strings to the same form makes them comparable:

  • NFC composes: e + accent becomes the single é. Usually what you want for storage and display.
  • NFD decomposes: é becomes e + accent.
  • NFKC and NFKD are the same two, plus compatibility decomposition, which is more aggressive.

This code picks NFKD, and the K is the interesting choice. Compatibility decomposition unpacks characters that are not accented letters but are typographic variants of something simpler:

"fi"  (single ligature glyph)  ->  "fi"
"Ⅻ"  (roman numeral twelve)   ->  "XII"
"①"  (circled one)            ->  "1"

I confirmed both of the first two land where you would hope: "fi ligature" gives fi_ligature, and "Ⅻ roman" gives xii_roman. Under plain NFD those characters would survive intact, get stripped as non-ASCII, and you would lose them entirely.

After normalizing, stripping the combining marks leaves plain ASCII letters behind, which is exactly what an identifier needs.

What NFKD will not do for you

Try this and it fails:

"Þór".normalize("NFKD")   // still contains Þ

Þ (thorn) is not a decorated T or a ligature. It is its own letter, from Old English and modern Icelandic, with no simpler form to decompose into. Same for Æ, Ø, Ł, and ß.

So NFKD leaves them, the ASCII filter drops them, and "Ólafur Þór" would become olafur_or. Which is wrong in a way the author of the name would find rude.

Hence the map:

const LATIN_TRANSLITERATIONS = new Map([
  ["Æ", "ae"], ["Ø", "o"], ["Ł", "l"], ["Þ", "th"], ["ß", "ss"], ...
]);

These are not arbitrary. Æ conventionally romanizes as ae, Þ as th, and ß as ss, which is what German itself does when uppercasing it. The probe confirms the map is reached: "Ólafur Þór" gives olafur_thor.

The general point: normalization gets you a long way and it is not transliteration. If your input can contain letters outside Latin-1, you either need a table for the ones that matter or a fallback for everything else.

The fallback, and where it goes

What about a name with no ASCII in it at all?

"日本語のアプリ"  ->  app_af6a6b6ae86e

No transliteration table is going to help here without becoming a romanization library. So the code gives up on readability and hashes:

const candidate = readable.length === 0
  ? `app_${stableDigest(normalizedName)}`
  : ...

Unreadable, deterministic, and valid. The user can always pass an explicit key if they want something meaningful, which is the right escape hatch.

One detail in that line is worth pointing out because it is easy to get backwards. stableDigest is given normalizedName, not the original name. If it hashed the raw input, the digest path would lose the normalization stability the readable path has, and "日本語" from a Mac and from Linux would produce different keys. Normalize first, then everything downstream inherits it.

Bounding the length

Identifiers usually have a maximum. Here it is 63 bytes, chosen to fit an iOS application identifier component.

Truncation alone is not enough, because two long names sharing a prefix would collapse to the same key. So:

const suffix = `_${stableDigest(normalizedName)}`;
const prefix = candidate
  .slice(0, MAX_DERIVED_APPLICATION_KEY_BYTES - suffix.length)
  .replace(/_+$/, "");
return `${prefix}${suffix}`;

Readable prefix, plus a digest of the whole name. You keep enough to recognise it and enough entropy to distinguish it. I checked the boundary with a 200-character name and got exactly 63 bytes, not 62 or 64.

The .replace(/_+$/, "") is a small nicety: without it you could land on movie_night__a1b2c3 with a doubled underscore where the truncation happened to fall on a separator.

Note it measures with Buffer.byteLength, not .length. Those differ for anything non-ASCII, and a limit expressed in bytes has to be checked in bytes. "日本語".length is 3 and its UTF-8 byte length is 9.

Deliberately throwing information away

One more thing worth understanding rather than treating as a bug. These all produce the same key:

movie_night <- "Movie Night"
movie_night <- "movie night"
movie_night <- "MOVIE-NIGHT"
movie_night <- "🎬 Movie Night"

The derivation is not injective. Case is folded, punctuation and emoji are dropped, whitespace runs collapse. Different names, one key.

Is that a bug? It depends entirely on whether anything requires keys to be unique. I checked the service side: application_key is NOT NULL with a format check constraint, and there is no unique index and no uniqueness validation. So two projects can share a key and nothing breaks.

Which makes it a reasonable design. The derived key is a convenience default, not an identity. If uniqueness were ever added, this derivation would become a source of confusing failures, and the right response then would be to stop deriving rather than to make the derivation cleverer.

The habit worth taking: when you write a lossy transformation, know what depends on it being lossless. Usually nothing does, and then lossy is fine. Occasionally something does, and it is much better to find that out now than from a constraint violation later.

Checklist

Deriving an identifier from human text, in order:

  1. Normalize, and pick the form deliberately. NFKD when you want ligatures and typographic variants unpacked.
  2. Transliterate the letters normalization cannot help with, if your users have them.
  3. Filter to your allowed character set.
  4. Handle the empty result, because it will happen.
  5. Fix up the start if your format demands a letter first.
  6. Bound the length in the units your limit is expressed in, and add a digest rather than truncating bare.
  7. Test each step with names that are not English.

Record why normalization alone cannot preserve these letters before the ASCII identifier filter. This keeps the explicit map from looking arbitrary.
@raghubetina

Copy link
Copy Markdown
Contributor Author

Addressed the durable-code follow-up in 944f08a: the transliteration map now explains that NFKD does not decompose these Latin letters before ASCII filtering. The full repository check still passes (149/149 tests, package allowlist, and packed smoke).

The later Skills workflow no longer names plan subject-id; the replacement is firstdraft generate uuid. Collision suffixing remains intentionally out of scope because application keys are not identity or globally unique.

Preserve the reviewed application identity commits while bringing in the lockfile repair required by the Node 24 quality gate.
@raghubetina
raghubetina merged commit 054e314 into main Aug 4, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant