kiit-service-id
Shared identity vocabulary for a service: who it is, what kind of thing it is, and a caller-safe way to expose it.
A typed service identity with ownership, kind, environment, version and instance, a private and an external form, and attribute names that line up with OpenTelemetry's service attributes. Kotlin Multiplatform, with a native TypeScript port.
- Structured: A name like
acme:accounts.signup:apiinstead of a free string each subsystem makes up. - Safe to expose:
externalIdcarries no version, environment or instance. - Tiny: Zero dependencies, so any subsystem can use it.

Overview
Goals
Calls and services are hard to identify. This is most true of HTTP requests and of internal service to service calls. There is also no shared convention for naming a service. OpenTelemetry improved this with service attributes for telemetry, and kiit-service-id takes the idea a step further by putting those attributes in a static type and an interface.
The goals are:
- A contract for a service id, with attributes that describe the service.
- Attributes for ownership, service type, environment, version and more.
- Compatibility with OpenTelemetry service attributes, for telemetry integration.
- Security through two forms of the id: an external one (
externalId) and a private one (privateId). - A tiny, zero dependency library for Kotlin Multiplatform, with a native TypeScript port.
Features
| # | Feature | Description |
|---|---|---|
| 1 | Identity contract | IServiceId is the plain data contract, and ServiceId is the concrete type you build. |
| 2 | Identity chain | path, name, fullName, install and privateId, each adding one field to the one before. |
| 3 | External form | externalId is safe to send outside your trust boundary. |
| 4 | Descriptive attributes | Ownership (origin, team), Kind, environment, version, criticality, about, tags and uri. |
| 5 | OpenTelemetry compatible | The fields line up with OpenTelemetry service attributes. |
| 6 | Parsing | Rebuild an identity from a caller-id header value. |
| 7 | Zero dependencies | Kotlin Multiplatform, plus a native TypeScript port. |
Inspiration
| # | Source | What was drawn from it |
|---|---|---|
| 1 | OpenTelemetry service attributes | The attribute names, the criticality values, and the idea of a per instance id and a version. |
| 2 | kiit-codes | The origin and scope convention, shared with Status.origin and Status.scope. |
Activity
Used in production for years, as the original kiit version. This repository is that component extracted into its own module.
Resources
Prefer to see it work first? Jump to the Tutorial. Prefer the reasoning first? Keep reading.
Setup
Install
Imports
What to import to use the library.
- Kotlin
- TypeScript
import kiit.serviceid.Criticality
import kiit.serviceid.Kind
import kiit.serviceid.Provenance
import kiit.serviceid.ServiceId
import kiit.serviceid.Tag
import { Criticality, Kind, Provenance, ServiceId, Tag } from "@kiitdev/service-id";
Source
Example
Build an identity and read three forms of it:
- Kotlin
- TypeScript
val id = ServiceId.api(origin = "acme", scope = "accounts.signup", env = "qat")
println(id.name) // acme:accounts.signup:api
println(id.install) // acme:accounts.signup:api:qat:latest
println(id.externalId) // acme:accounts.signup
const id = ServiceId.api("acme", "accounts.signup", "qat");
console.log(id.name); // acme:accounts.signup:api
console.log(id.install); // acme:accounts.signup:api:qat:latest
console.log(id.externalId); // acme:accounts.signup
Explanation
Terms
| # | Name | Type | Example | Notes |
|---|---|---|---|---|
| 1 | origin | String | acme | Who owns the identity. A domain like label, the same convention as Status.origin in kiit-codes. Not validated. |
| 2 | scope | String | accounts.signup | Where in origin it lives. Dots express hierarchy. It can't contain a colon. |
| 3 | kind | Kind | Kind.API | The kind of runnable thing this is. See Kind values. |
| 4 | env | String | qat | The environment, as whatever label you already use. |
| 5 | version | String | 1.4.2 | The version running here. Defaults to latest. |
| 6 | instance | String | 4a3b300b | One running instance, to tell copies of the same version apart. Random by default. |
| 7 | about | String | Sends the welcome email | A short description of what the service does. Empty means unset. |
| 8 | tags | List<Tag> | Tag.Keyed("region", "us-east-1") | Labels on the identity: a bare value or a key and value. |
| 9 | uri | String? | worker-7.acme.internal | A reference to the instance itself, such as a hostname. Unique per environment. |
| 10 | criticality | Criticality | Criticality.High | How much it matters if the service fails or is unavailable. |
| 11 | team | String | payments-platform | The team that owns the service, such as a Slack team or a distribution list. Distinct from origin, the owning organization. |
| 12 | provenance | Provenance | Provenance.Declared | How the identity came to exist: Declared when built with of, Parsed when rebuilt from a string. |
Descriptive attributes. about, tags, uri, criticality, team and provenance are descriptive. They are
never part of an identifier, and the library has no health or ownership logic that acts on them.
Kind. Kind is a closed set of things that are runnable and deployable and that issue or serve requests. A new
kind is added only when it changes how a caller is attributed or handled. Gateway marks the edge where privateId
stops and externalId starts. Function marks short lived instances that run per request. The ones left out are
covered by an existing kind, or are not a caller at all:
- A scheduler or cron job is
Job. - A queue or stream consumer is
Worker. - A mobile or desktop app is
App, and a browser frontend isWeb. - A plugin or extension runs inside another process and has no identity of its own.
- A database, cache or queue is called but never calls, so it is not at the edge of a request.
Environment. env is a plain string, so the library does not enforce how you name environments.
Tags. A Tag is either a bare value or a key and value. Tag.parse splits on the first =. The delimiter is =
and not : so it doesn't clash with the : that builds the chain. Tags are stored as given and not normalized.
Tag lives in this module, and kiit-requests still has its own until it migrates.
Identity chain
Each accessor adds one field to the one before it:
pathis who:originandscope.nameadds thekind.fullNameadds theenv.installadds theversion.privateIdadds theinstance.
Each level answers a different question: this component, in one environment, at one version, as one running
instance. The cost is six fixed segments, so : is reserved. See the values in Accessors.
Security
externalId is an alias for path. It carries only origin and scope, with no operational detail, so it is
the form to expose. privateId carries the exact version, environment and instance, and belongs inside your
trust boundary. Outside it, those details are reconnaissance material, the same kind of risk as leaving a Server
or X-Powered-By header exposed. See OWASP's guidance on
fingerprinting a web application framework.
- Kotlin
- TypeScript
val id = ServiceId.of("acme", "accounts.signup", Kind.API, "qat", version = "1.4.2", instance = "4a3b300b")
println(id.privateId) // acme:accounts.signup:api:qat:1.4.2:4a3b300b, for internal calls only
println(id.externalId) // acme:accounts.signup, safe to send outside
const id = ServiceId.of({
origin: "acme",
scope: "accounts.signup",
kind: Kind.API,
env: "qat",
version: "1.4.2",
instance: "4a3b300b",
});
console.log(id.privateId); // acme:accounts.signup:api:qat:1.4.2:4a3b300b, for internal calls only
console.log(id.externalId); // acme:accounts.signup, safe to send outside
The cost is that callers have to choose. Nothing stops code from sending privateId outside, so keeping it
internal is a convention and not a check.
- Internal only: Use
privateIdfor trusted service to service traffic. - Outside: Send
externalIdwhen a call leaves your infrastructure. - Not for decisions: Don't let a parsed
privateIddecide authorization for a request that could have come from outside.
Construction
ServiceId.of and its shortcuts are the way to build an identity. The constructor is internal.
Normalization. of lowercases origin, scope, env and version, and strips every character except letters,
digits, -, _, . and spaces, turning spaces into _. instance is validated and not normalized. Folding its
case could make two different instances collide, so of only checks that it has no :.
Immutability. with() and newInstance() return a new value, so an identity can be shared without being
changed. The cost is that copy() is internal too, so code inside the module can still make an un-normalized
copy. The library accepts that gap rather than give up data class.
Equality. Two identities are equal when their privateId is equal. about, tags, uri, criticality, team
and provenance don't count. The reason is that a server treats two requests as the same caller when the
caller-id header value matches, and nothing else travels on the wire. equals, hashCode and toString are
overridden together to match.
- Kotlin
- TypeScript
val first = ServiceId.job("acme", "accounts.signup")
val second = first.newInstance()
println(first == second) // false: a new instance id gives a new privateId
println(first.path == second.path) // true: everything above the instance is the same
const first = ServiceId.job("acme", "accounts.signup");
const second = first.newInstance();
console.log(first.equals(second)); // false: a new instance id gives a new privateId
console.log(first.path === second.path); // true: everything above the instance is the same
Contract and implementation
IServiceId is a bare data contract. The derived accessors (path, name, fullName, install,
privateId and externalId) live only on ServiceId. Implementing IServiceId for a custom shape shouldn't
promise accessors that shape never defined. The cost is that a custom implementation gets none of them.
Parsing
ServiceId.parse rebuilds an identity from a privateId string, such as a caller-id header value. It is
strict and matches of and with, which also reject bad input:
- It needs exactly six segments, a known kind and no blank segment.
- It lowercases
origin,scope,kind,envandversion, so a parsed identity holds the same field values as one built withof. - It rejects, and does not rewrite, a segment with characters
ofwould strip, so untrusted header text is never silently changed into something else. - It leaves
instanceas given, the same asof.
Only the six chain fields come back. The descriptive fields get their defaults, and provenance is Parsed.
- Kotlin
- TypeScript
val parsed = ServiceId.parse("acme:accounts.signup:api:qat:1.4.2:4a3b300b")
println(parsed.provenance) // Parsed
println(parsed.tags) // []
const parsed = ServiceId.parse("acme:accounts.signup:api:qat:1.4.2:4a3b300b");
console.log(parsed.provenance); // Parsed
console.log(parsed.tags); // []
OpenTelemetry
OpenTelemetry's resource describes the producer of telemetry. It is set once per process and attached to every
signal. A ServiceId is a value that travels in requests, is parsed and compared, and keys caches and jobs.
criticality is borrowed from OpenTelemetry's service.criticality.
It is not built on the resource model, for three reasons:
- Different identity: OpenTelemetry treats
deployment.environment.nameandservice.versionas not part of a service's identity.ServiceIdequality isprivateId, which includes both, because a server sees that string in acaller-idheader and two builds are different callers. - Different shape: A resource is an open attribute map.
ServiceIdhas fixed fields, derived string forms and a strictparse, and it addskind,externalIdandprovenance, which resources don't have. - No dependency: The module has none, and a resource model would add one to every subsystem that uses it.
The service fields do map onto resource attributes. A one way conversion belongs in a separate telemetry
adapter and not in this module. The open choice is origin against service.namespace: origin is the owning
organization, and OpenTelemetry's namespace is a grouping inside it. See the table in
OpenTelemetry mapping.
Limitations
- The trust boundary is a convention, not a check.
- Tags are stored as given, not normalized.
Kindis a closed set.
Tutorial
Create
This walks through creating an identity, sending it between two services, and reading it on the other side.
- Start here: Build an identity with a shortcut such as
ServiceId.api. - Add as needed: Reach for
ServiceId.ofwhen you need a kind or an option the shortcuts don't cover.
Create an identity and print each form of it:
- Kotlin
- TypeScript
val id = ServiceId.api("acme", "accounts.signup", "qat")
println("path=${id.path}")
println("name=${id.name}")
println("fullName=${id.fullName}")
println("install=${id.install}")
println("privateId=${id.privateId}")
println("externalId=${id.externalId}")
const id = ServiceId.api("acme", "accounts.signup", "qat");
console.log(`path=${id.path}`);
console.log(`name=${id.name}`);
console.log(`fullName=${id.fullName}`);
console.log(`install=${id.install}`);
console.log(`privateId=${id.privateId}`);
console.log(`externalId=${id.externalId}`);
Copy
An identity never changes. with returns a new one, here with two tags:
- Kotlin
- TypeScript
val original = ServiceId.job("acme", "accounts.signup")
val tagged = original.with(inst = null, tags = listOf(Tag.Basic("retry"), Tag.Keyed("batch", "42")))
println("original tags=${original.tags}") // []
println("tagged tags=${tagged.tags}")
const original = ServiceId.job("acme", "accounts.signup");
const tagged = original.with(null, [Tag.Basic("retry"), Tag.Keyed("batch", "42")]);
console.log(`original tags=[${original.tags.map((t) => t.raw).join(", ")}]`); // []
console.log(`tagged tags=[${tagged.tags.map((t) => t.raw).join(", ")}]`);
Send
Send the private id to another internal service in a caller-id header:
- Kotlin
- TypeScript
val headers = mapOf("caller-id" to caller.privateId)
println(headers["caller-id"]) // acme:accounts.signup:api:qat:1.4.2:4a3b300b
const headers: Record<string, string> = { "caller-id": caller.privateId };
console.log(headers["caller-id"]); // acme:accounts.signup:api:qat:1.4.2:4a3b300b
Receive
On the receiving side, parse the header back into an identity and read its fields:
- Kotlin
- TypeScript
val received = ServiceId.parse(headers.getValue("caller-id"))
println(received.origin) // acme
println(received.scope) // accounts.signup
const received = ServiceId.parse(headers["caller-id"]);
console.log(received.origin); // acme
console.log(received.scope); // accounts.signup
Send outside
When a call leaves your infrastructure, send the external id instead:
- Kotlin
- TypeScript
val outbound = mapOf("caller-id" to caller.externalId)
println(outbound["caller-id"]) // acme:accounts.signup
const outbound: Record<string, string> = { "caller-id": caller.externalId };
console.log(outbound["caller-id"]); // acme:accounts.signup
Guide
Choose a kind
Only app, api, cli, job and test have shortcuts. For any other kind, such as a frontend (Web), a
gateway or an AI agent, build the identity with ServiceId.of. See Terms for how to choose between
kinds.
- Kotlin
- TypeScript
val frontend = ServiceId.of("acme", "storefront", Kind.Web, "pro")
val edge = ServiceId.of("acme", "edge", Kind.Gateway, "pro")
val assistant = ServiceId.of("acme", "support.assistant", Kind.Agent, "pro")
println(frontend.name) // acme:storefront:web
println(edge.name) // acme:edge:gateway
println(assistant.name) // acme:support.assistant:agent
const frontend = ServiceId.of({ origin: "acme", scope: "storefront", kind: Kind.Web, env: "pro" });
const edge = ServiceId.of({ origin: "acme", scope: "edge", kind: Kind.Gateway, env: "pro" });
const assistant = ServiceId.of({ origin: "acme", scope: "support.assistant", kind: Kind.Agent, env: "pro" });
console.log(frontend.name); // acme:storefront:web
console.log(edge.name); // acme:edge:gateway
console.log(assistant.name); // acme:support.assistant:agent
Strip at the gateway
At the edge, replace the internal id with the external one before a call leaves your infrastructure:
- Kotlin
- TypeScript
/** At the edge: replace the internal id with the external one before a call leaves your infrastructure. */
fun toExternal(headers: Map<String, String>): Map<String, String> {
val internal = ServiceId.parse(headers.getValue("caller-id"))
return headers + ("caller-id" to internal.externalId)
}
/** At the edge: replace the internal id with the external one before a call leaves your infrastructure. */
function toExternal(headers: Record<string, string>): Record<string, string> {
const internal = ServiceId.parse(headers["caller-id"]);
return { ...headers, "caller-id": internal.externalId };
}
Attach tags
A tag is either a bare value or a key and value. Tag.parse reads either form:
- Kotlin
- TypeScript
val tags = listOf(Tag.Basic("retry"), Tag.parse("region=us-east-1"))
val tagged = ServiceId.job("acme", "accounts.signup").with(inst = null, tags = tags)
println(tagged.tags) // [Basic(value=retry), Keyed(key=region, value=us-east-1)]
const tags = [Tag.Basic("retry"), Tag.parse("region=us-east-1")];
const tagged = ServiceId.job("acme", "accounts.signup").with(null, tags);
console.log(tagged.tags.map((t) => t.raw)); // [ 'retry', 'region=us-east-1' ]
Set ownership and criticality
Describe the service with team, criticality, about and uri. None of them is part of an identifier:
- Kotlin
- TypeScript
val worker =
ServiceId.of(
origin = "acme",
scope = "accounts.signup",
kind = Kind.Worker,
env = "pro",
version = "1.0.2",
about = "Sends the welcome email after signup",
uri = "worker-7.acme.internal",
criticality = Criticality.High,
team = "payments-platform",
)
println(worker.criticality) // High
println(worker.team) // payments-platform
println(worker.provenance) // Declared
const worker = ServiceId.of({
origin: "acme",
scope: "accounts.signup",
kind: Kind.Worker,
env: "pro",
version: "1.0.2",
about: "Sends the welcome email after signup",
uri: "worker-7.acme.internal",
criticality: Criticality.High,
team: "payments-platform",
});
console.log(worker.criticality); // High
console.log(worker.team); // payments-platform
console.log(worker.provenance); // Declared
Handle a bad id
parse throws IllegalArgumentException, and Error in TypeScript, with the reason. Catch it where you read
the header:
- Kotlin
- TypeScript
try {
ServiceId.parse("acme:accounts.signup")
} catch (e: IllegalArgumentException) {
println(e.message) // expected 6 segments (origin:scope:kind:env:version:instance), got 2: ...
}
try {
ServiceId.parse("acme:accounts.signup");
} catch (e) {
console.log((e as Error).message); // expected 6 segments (origin:scope:kind:env:version:instance), got 2: ...
}
Use it from TypeScript
The TypeScript port has the same model, with a few differences in shape:
ServiceId.oftakes one options object and not named arguments.Kindis an object with a matching union type, andTagcarries avariantfield to narrow on, since TypeScript has no sealed classes.- Equality is the
equalsmethod, and comparesprivateIdas in Kotlin.
Every example above has a TypeScript tab.
Reference
Lookup tables. The ideas behind them are in Explanation.
Accessors
Values for ServiceId.api("acme", "accounts.signup", "qat") at version 1.4.2:
| Accessor | Adds | Example |
|---|---|---|
path | origin, scope | acme:accounts.signup |
name | kind | acme:accounts.signup:api |
fullName | env | acme:accounts.signup:api:qat |
install | version | acme:accounts.signup:api:qat:1.4.2 |
privateId | instance | acme:accounts.signup:api:qat:1.4.2:4a3b300b-d0ac-4776-8a9c-31aa75e412b3 |
externalId | alias for path | acme:accounts.signup |
Every segment except instance is lowercased.
Kind values
| Kind | For |
|---|---|
App | A runnable application. Also mobile and desktop apps. |
CLI | A command line tool. |
Web | A browser frontend. |
API | An HTTP service. |
Bot | A bot that is not an AI agent. |
Job | Scheduled or one off work. |
Worker | A queue or stream consumer, or a worker in a pool. |
Service | A deployable service that doesn't fit another kind. |
Gateway | An edge or routing service in front of others: an API gateway or reverse proxy. |
Function | A serverless function: short lived and triggered per event or request. |
Agent | An AI agent: software that acts on its own judgment. |
Test | A test identity. |
Criticality values
| Value | Meaning |
|---|---|
Unspecified | No criticality declared. The default. |
Low | Low impact if it fails. |
Medium | Medium impact if it fails. |
High | High impact if it fails. |
Critical | Critical impact if it fails. |
Tag forms
| Form | Built with | raw |
|---|---|---|
Tag.Basic | Tag.Basic("retry") | retry |
Tag.Keyed | Tag.Keyed("region", "us-east-1") | region=us-east-1 |
| Either, from a string | Tag.parse("region=us-east-1") | Splits on the first = |
Fields and defaults
The options of ServiceId.of:
| Field | Default | Normalized |
|---|---|---|
origin | required | yes |
scope | required | yes |
kind | required | no |
env | dev | yes |
about | empty | no |
version | latest | yes |
instance | random UUID | no, validated |
tags | none | no |
uri | none | no |
criticality | Unspecified | no |
team | empty | no |
provenance | Declared, not an option | no |
OpenTelemetry mapping
How each field lines up with an OpenTelemetry attribute. The origin and scope rows are approximate.
ServiceId | OpenTelemetry | Fit |
|---|---|---|
scope | service.name | Approximate. scope is a dotted hierarchy. |
origin | service.namespace | Approximate. origin is the owning organization. |
instance | service.instance.id | Same idea. |
version | service.version | Same. |
env | deployment.environment.name | Same value. OpenTelemetry keeps it out of a service's identity. |
criticality | service.criticality | Same idea. OpenTelemetry writes the values in lowercase. |
team | none | ServiceId only. Kiit specific: the team that owns the service, such as a Slack team or a distribution list. |
kind, about, tags, uri, provenance | none | ServiceId only. |
Source: OpenTelemetry service attributes.
FAQ
Common questions about the design, alternatives, adoption and maturity.
Why
| Question | Answer |
|---|---|
| Why not a string constant per subsystem? | A string has no shared shape and no parse, and it is easy to leak. This module exists to replace it with one identity that every subsystem can use. |
| When is this not needed? | For outgoing requests to public or external services that are not owned or managed by your team and company. For these, do NOT use privateId, you can use externalId but this is also optional. |
Alternatives
| Question | Answer |
|---|---|
| How is this different from OpenTelemetry resource attributes? | A resource describes the producer of telemetry. A ServiceId travels in requests and is parsed and compared. Identity, shape and dependencies also differ. See OpenTelemetry. |
| Can I send these to OpenTelemetry? | The service fields map onto resource attributes. The conversion belongs in a separate telemetry adapter, not in this module. See OpenTelemetry mapping. |
API
| Question | Answer |
|---|---|
Why is privateId unsafe to expose, and what do I send instead? | It carries the exact version, environment and instance. Send externalId, which carries only origin and scope. See External and private ids. |
Why is equality only on privateId? | It is the value a server sees in a caller-id header. See Construction. |
Can I implement IServiceId myself? | Yes, but you get none of the accessors, which live only on ServiceId. See Contract and implementation. |
Why is env a string and not an enum? | So the library doesn't enforce how you name environments. |
Why is Kind a closed set, and which kinds were left out? | See Terms. |
Adoption
| Question | Answer |
|---|---|
| Can I adopt it in one subsystem first? | Yes, start with just adding it for requests to your own internal services as a header. |
AI
| Question | Answer |
|---|---|
| How does this help AI tooling? | A structured origin:scope:kind id lets a tool tie the caller-id on a request to a git repo and a service when debugging it. Kind.Agent marks an AI agent as the caller. |
Maturity
| Question | Answer |
|---|---|
| Is it used in production? | Yes, for years, as the original kiit component this was extracted from. |