Identity Store Specification, v4
Table of Contents
The stack | v1 | v2 | v3 | v4 | v5 | v6 | v7
Status
Speculative. Nothing here is built. It answers one question: if the store had to be handed to another agent as a specification, with no access to the store, the tools or the session that made them, what would the specification have to say for that agent to build something compatible from first principles?
"Sealed" means the reader gets this document and nothing else. So it defines every term it uses, it prefers one rule to two, and where the store it was written from has two shapes for one thing it picks one.
The notation is Z in ASCII, loosely: given sets in brackets, schemas as named blocks of declarations over a line and predicates under it, a prime for the state after an operation. It is used to be exact, not to be checked; no tool has read it.
Words used as in RFC 2119: MUST, MUST NOT, SHOULD, MAY.
This is one of a stack of drafts, v2 to v7, each the whole of the specification and each the one before it with one change. v1 was sealed on 2026-10-10 and is not edited. Drafts are sealed one at a time, lowest first, each in a repository of its own, and are not edited once sealed. What review of a sealed draft finds, and what a build against it finds, is recorded beside it and carried into the draft above before that one is sealed. Section Changes says what this draft adds; section Review says what its reviewer is asked.
This draft: v4, generations.
1. Scope
In scope: where a credential is filed, what a filed credential contains, what is recorded about it in the clear, who can decrypt it, and the operations that change any of those.
Out of scope, on purpose: how a provider's sign-up works; which providers exist; how an agent is told which identity it is; transport of secrets to another machine; backup. A conforming build MAY add these and MUST NOT need them to pass section 8.
2. Given sets and types
[SECRET] opaque text; never compared, logged or printed
[SEG] a path segment: one or more of a-z 0-9 . _ @ -
[DATE] a calendar date, YYYY-MM-DD
[KEYID] an encryption key's identifier
Kind ::= login | otp | recovery | token | key | pin
Effect ::= private | staged | outward
State ::= held | wanted | elsewhere | dropped
Class ::= person | agent | role | project | site
Env ::= local | dev | test | staging | prod | shared
A SEG MUST NOT be a member of Kind. That one restriction is what makes
a path parse without knowing its owner.
What each kind is. The definitions are the specification; the examples are not.
| kind | definition | issued by | revocable |
|---|---|---|---|
login |
what is typed to begin a session as the account | holder | by change |
otp |
a shared secret from which time-based codes are derived | provider | by reset |
recovery |
a value the provider accepts in place of a lost factor | provider | by reissue |
token |
a credential the provider issued to act as the account without a session | provider | yes, singly |
key |
key material whose loss cannot be repaired by asking anyone | holder | no |
pin |
a short value that is the holder's own and is asked for by others | nobody | no |
The last two columns decide the kind when the provider's own word for a
thing disagrees. A provider's "app password" is a token. A provider's
"access key and secret" is a token. A passport number is a pin.
3. Paths
One shape, for every owner:
path = owner "/" realm "/" handle "/" kind [ "/" name ]
owner = "me"
| "agent/" SEG ; a named agent identity
| "role/" SEG ; an identity defined by what it does
| "proj/" SEG "/" Env ; a project, then its environment
| "site/" SEG ; a place: a network, a building
realm = SEG ; where the credential is honoured
handle = SEG ; who the realm thinks this is
kind = "login" | "otp" | "recovery" | "token" | "key" | "pin"
name = SEG
Rules, each of which a validator can check from the path alone:
- P1
- Exactly one segment of a path is a
Kind. - P2
nameMUST be present fortoken, MUST be absent forlogin,otpandrecovery, and MAY be present forkeyandpin.- P3
realmis the provider's registrable domain when it has one (github.com, notGitHub, notgh), in normal form (P8). When it has none, it ishost.<name>for a service on a named machine,net.<name>for a network, ordocfor something that is not an account at all. A name the provider serves only on a private network, such as one under.internal, is a realm as it is spelled in DNS.- P4
handleis what the realm calls the account, in normal form (P8). When the realm has no accounts,handleis the literal_. Words such asdefault,adminormasterare handles only when the realm really has an account of that name. When normal form changes the handle (case, or a character outsideSEG), the realm's own spelling is kept whole in theaccountfield.- P8
- Normal form. A realm is lower case, has no trailing dot, and an
internationalised name is written as its A-label (
xn--bcher-kva.exampleforbücher.example). A handle is lower case, and each character outsideSEGbecomes-. Normal form MUST be injective within a realm: if two accounts at one realm would normalise to one handle, the second uses the realm's numeric account id instead. - P5
- A token's
nameis the holder: the machine or the service that presents it (laptop,ci,staging-jail). What it may do is a field (section 4), never part of the name. Two holders MUST have two tokens. - P6
- For
key,nameis<algorithm-or-use>-<holder>-<yyyy-mm>. - P7
- The same account is the same
owner/realm/handleprefix for all of its kinds. An account's entries are found by listing a prefix.
Whose it is:
- O1
- A credential that lets the bearer act as an account is owned by the identity the account belongs to.
- O2
- A credential that identifies an application or a deployment to a
provider is owned by
proj/<project>/<environment>. - O3
- A credential that admits anyone standing in a place (a wireless
passphrase, a door code) is owned by
site/<place>. - O4
- No two owners share an entry. If two need the same access, the provider issues two credentials.
Environments:
- N1
- A project uses the members of
Envit has; a library may have onlytestandshared.sharedholds what every environment of the project presents to one provider, such as a domain registrar, and is its own owner. - N2
- No environment reads another. A reader of
proj/p/devis not thereby a reader ofproj/p/prodor ofproj/p/shared, and a build MUST NOT fall back from a missing entry in one environment to the same path inshared. - N3
- Only a project has environments. A role reaches an environment
through the
scopeof a token it holds, never through its path.
4. Entries
Entry
path : Path
secret : SECRET
fields : FieldName -+-> TEXT
----------------------------------------------------------
line 1 of the stored text is secret and nothing else
every later line is name ": " value
dom fields is a subset of FieldName
FieldName is closed:
| field | required for | value |
|---|---|---|
issued |
every entry | DATE |
expires |
token; else optional |
DATE, or the literal never |
effect |
token, login |
Effect |
scope |
token |
the provider's own words for what it permits |
used-by |
token |
what presents it, in a sentence |
url |
optional | where it is typed or managed |
account |
optional | another name the realm knows the account by (an email, an id) |
fictional |
optional | yes when the secret on line 1 was made up |
note |
optional | one line |
generation |
a derived secret | a positive integer: the counter the secret was derived with |
derived-from |
a derived secret | the path of the entry the secret is derived from |
- E1
- A reader of an entry MAY rely on line 1 and on the fields above and on nothing else.
- E2
- An
otpsecret is anotpauth://URI, whole. - E3
- A
recoveryentry holds every unspent code, one per line after line 1 ascode: <value>, and line 1 is the first unspent. Spending a code removes it. Removal is by value, never by position. - E4
- One entry holds one thing. A real value and a made-up value MUST NOT share an entry.
- E5
effectis the worst thing the bearer can do, not the usual thing: a token that can publish isoutwardeven if it never has.- E6
- An entry is derived when its secret is computed from another
entry's secret and a counter, not issued by the provider or chosen by
the holder. A derived entry has
generationandderived-from; no other entry has either. - E7
derived-fromnames a path indom entries. The source need not be readable by the derived entry's readers; deriving is then a custodian's operation (S3), and the readers hold only the result.
5. The record in the clear
Record
path : Path
state : State
due : DATE | none
effect : Effect | none
reason : TEXT ; required when state = dropped
Store
entries : Path -+-> Entry
records : Path -+-> Record
readers : Owner --> P KEYID
----------------------------------------------------------
I1 dom entries = { p : dom records | records(p).state = held }
I2 for all p in dom entries: records(p).due = entries(p).expires
I3 for all p in dom entries: records(p).effect = entries(p).effect
I4 for all p in dom entries: P1..P8 hold of p, E1..E7 of entries(p)
I5 for all o: readers(o) is not empty
I6 for all p in dom entries:
entries(p) is decryptable by exactly readers(owner(p))
The record exists so that three questions are answered without decrypting anything: what is held, what ends when, and what could be done with it. I2 and I3 say the record is a projection of the entries: a build MUST derive it, or check it, mechanically. A record kept by hand is not conforming, however careful the hand.
6. Isolation
- S1
readersis per owner.readers(me)andreaders(agent/x)are different sets unless someone decided otherwise and wrote down why.- S2
- A process acting as owner o is given a key in
readers(o)and no other. That it cannot readme/is then a fact about keys, not about where it was told to look. - S3
- A custodian MAY be a reader of every owner. This is the "one custodian, fanned out by issuing" arrangement: the custodian holds everything and hands out by issuing a narrower credential, never by sharing an entry.
- S4
- A secret that reaches a tool reaches it on standard input or in the environment of exactly one child process. It MUST NOT be a command argument, a file in a checkout, or a line of output.
- S5
- An empty read is a failure. A tool that reads nothing MUST stop; it MUST NOT continue with an empty value, because the tool it feeds will then fall back to whatever ambient credential the machine has.
7. Operations
Each is given as what must be true before and what is true after. A build names them as it likes.
Enrol (p : Path; s : SECRET; f : fields)
pre p not in dom entries; P1..P8 of p; f complete for kind(p)
post entries' = entries + {p |-> (s, f)}; records'(p).state = held
Issue (account; holder : SEG; scope; expires : DATE; effect)
pre the provider has issued a token to this holder and no other
post Enrol(account/token/holder, ...); records'(p).due = expires
Read (p : Path; consumer)
pre p in dom entries; consumer's key in readers(owner(p))
post Store unchanged; consumer holds secret; nothing printed
Rotate (p : Path; s' : SECRET)
pre p in dom entries; the provider already honours s'
today >= entries(p).issued
p derived => s' is derived with entries(p).generation + 1
post entries'(p).secret = s'; entries'(p).issued = today
p derived => entries'(p).generation = entries(p).generation + 1
the old secret is refused by the provider <- checked, not assumed
Spend (p : Path; c) ; kind(p) = recovery
pre c is a code of entries(p)
post c is not a code of entries'(p)
Revoke (p : Path; why : TEXT)
pre p in dom entries; the provider refuses the secret
post p not in dom entries'; records'(p) = (dropped, why)
Rename (p, q : Path)
pre p in dom entries; q not in dom entries; owner(p) = owner(q)
post entries'(q) = entries(p); p not in dom entries'
every consumer that named p names q <- or the rename is not done
Audit ()
post Store unchanged; reports every violation of I1..I6
Two of these carry the lessons that cost the most to learn:
RotateandRevokeare not finished when the store changes. They are finished when the provider has been asked with the old secret and has said no.Renamemoving an entry across owners is not a rename. It is aRevokeand anIssue, because O4 says the two owners never held the same credential.
8. Conformance
A build conforms when Audit reports nothing on a store it made, and when
it gives these answers.
Paths, accepted:
me/github.com/jdoe/login
agent/nova/pypi.org/nova-dev/token/laptop
agent/nova/doc/_/key/ssh-laptop-2026-10
proj/workshop/staging/github.com/workshop-staging/token/staging-jail
site/home/net.guest/_/login
me/doc/_/pin/passport
Paths, refused, with the rule:
nova/pypi.org/nova-dev/token/laptop no such owner (grammar)
agent/nova/pypi.org/nova-dev/token token without a name (P2)
agent/nova/pypi.org/nova-dev/login/web login with a name (P2)
agent/nova/pypi/nova-dev/login realm not a domain (P3)
agent/nova/key/ssh/laptop kind not after handle (grammar)
agent/nova/pypi.org/nova-dev/token/publish name is a scope (P5)
me/host.box/token/token/laptop a segment is a Kind (given sets)
me/github.com./jdoe/login trailing dot (P8)
me/github.com/j.doe+ci/login not in SEG (P8: j.doe-ci)
proj/workshop/production/github.com/workshop/token/ci not an Env (grammar)
role/builder/prod/github.com/jdoe/token/builder-laptop kind not after handle (grammar, which is N3)
Paths, accepted since v2:
me/xn--bcher-kva.example/jdoe/login an A-label realm
role/builder/llm-gateway.internal/_/token/builder-laptop
Behaviour:
- C1
- Enrolling a
tokenwithoutexpires,effect,scopeandused-byis refused. - C2
- After any operation, the record equals the projection of the entries (I1..I3).
- C3
- A process holding only an agent's key cannot decrypt any path
under
me/. Tested by trying. - C4
Readwith the store unreadable exits non-zero and starts no child process.- C5
- No operation's command line, as seen in a process listing, contains a secret.
- C6
Spendon a store with codesa b c, asked to spendb, leavesa c, whatever order the entry lists them in.- C7
- Enrolling
JDoeat a realm wherejdoeis already enrolled for a different account is refused, or is enrolled under the realm's numeric id (P8, injective). - C8
Readofproj/p/dev/r/h/loginwhen onlyproj/p/shared/r/h/loginexists fails as an empty read (S5); it does not return thesharedsecret (N2).- C9
Rotateof a derived entry at generation 3 with a secret derived at generation 2, or at 5, is refused; at 4 it succeeds and the entry readsgeneration: 4.- C10
- An entry with
generationand noderived-from, or with aderived-fromnaming no entry, is reported byAudit(E6, E7).
9. Left open
Stated so that a builder does not mistake silence for a decision.
- Whether the record is a file, a database or generated on demand.
- Whether
readersis enforced by encryption recipients, by separate stores, or by a broker that holds the only key. S2 is satisfied by any. - How one identity's several handles are tied together. P7 ties an
account's kinds; nothing ties
nova-devat one realm tonova-ciat another except the owner above both. An index from identity to accounts may be wanted and is not specified. - Second factors that are hardware. They are not entries: there is nothing to store. Whether the record lists which keys are registered where is left open, and it is the thing a second custodian would most need.
- A mailbox is the root of recovery for most accounts under an owner. The
specification treats it as one more
login. It probably deserves a rule of its own. - Whether
pinshould exist. Its contents are not credentials, most cannot be rotated, and E4 with thefictionalfield exists only because of it. A wallet and a credential store may be two things that share an encryption key.
Changes from the version below
Newest first. Each entry names the rules it touches, so a reviewer can read only those.
v4 from v3: generations
v1's Rotate assumed the new secret came from the provider. Some do not:
a password derived from a master secret and a counter, a database
password a build computes, a key stretched from a passphrase. For those
the store holds the counter, and v1 had nowhere to put it and nothing to
stop it going backwards, which reissues a secret already retired.
FieldName- two more,
generationandderived-from; eleven, closed. - E6, E7 (new, §4)
- when the two fields are present, and that a derived entry's source is named by path.
Rotate- for a derived entry, the generation rises by exactly one;
for any entry,
issuednever goes back. - §8
- C9, C10.
v3 from v2: environments
v1 put an environment in every project's path and left its values open,
so prod, production and live were three owners for one thing.
Env(new given set)- closed:
local dev test staging prod shared. - grammar
proj/SEG/Env.- N1-N3 (new, §3)
- what
sharedmeans; environments never cross; a role reaches an environment through a token'sscope, not its path. - §8
- two refused paths, C8.
Nothing else moves: O4 already made a move between environments a
Revoke and an Issue, because the environment is part of the owner.
v2 from v1: names in normal form
v1's SEG already forbids upper case, so P3's "not GitHub" held, but a
trailing dot passed, an internationalised name had no spelling, and P4's
"verbatim, lower case" contradicted itself for any realm that keeps case
or allows characters outside SEG.
- P8 (new)
- one normal form for realms and handles, injective within a realm.
- P3, P4
- point at P8; P4 keeps the realm's own spelling in
account; P3 admits a private DNS name as a realm. - I4,
Enrol - P1..P8.
- §8
- two refused paths, two accepted, C7.
Why it comes first: everything stacked on it joins on names. v5 keys runtime records by path and v6 joins a route to an entry by realm; both are only as good as two spellings of one name being one string.
Review
Answers are recorded beside the sealed draft and carried into the draft above it before that one is sealed.
- E7 lets a role's entry be derived from a source its readers cannot decrypt; only a custodian (S3) can rotate it. Is that the intent, or should a derived entry's source share its owner?
- Should the record carry the generation, so that "which generation is live" is answered without decrypting? It leaks how often a secret has turned over, which is little.
- Is "rises by exactly one" too strict for a provider that skips numbers, and should skipped generations be listed as retired?
What this would not have caught
A specification of where secrets are filed says nothing about the 26
.env files found the same day, which are the larger exposure, nor about
secrets pasted into a working session. A build could conform to every
line above on a machine where an agent can read a production token from a
checkout. Section 6 is necessary and nowhere near sufficient.