ADR-038: Stable Public Keys in the API
Status
Accepted and built (2026-10-28). In a scratch org PublicKeyServiceTest, SAPIPublicKeyTest and PAPIPublicKeyTest passed 55 of 55, and every local test, the existing SAPI and PAPI tests among them, passed 1292 of 1292. Rows that existed before the field get their keys when scripts/backfill-public-keys.apex runs once in production.
Context
A reviewer noted that the API returns Salesforce record Ids. It is not a security problem. The API runs as a read only integration user, tokens are scoped, and an Id grants nothing by itself. It is a design smell all the same. A public API should expose keys it controls, not the internal row Ids of the system behind it. An Id ties every client to Salesforce, changes when a record is deleted and recreated in another org, and shows a reader the platform and the object from its first three characters.
I chose an additive change to v1, not a break. Nothing is removed. Removal would be v2.
Decision
Every object the API exposes gets a stable Public_Key__c. The API returns it beside id, accepts it anywhere an Id is accepted, and the specs mark id as deprecated.
- Field.
Public_Key__con 13 objects. Text(24), External ID, unique, case insensitive, not required at the field level, because rows that existed before the field get their key from the backfill. - Format. An object prefix, an underscore and 10 random characters from
abcdefghijkmnpqrstuvwxyz23456789. The alphabet leaves outl,o,0and1, which are easy to misread. A key is 14 characters, for exampleprj_8f3k2m9qtz. It is opaque on purpose. It never encodes a name, so renaming a record never changes it and no personal detail leaks. The 10 characters carry 50 bits, so a collision is rare, and the unique index backs the check. - Prefixes.
| Object | Prefix | Object | Prefix |
|---|---|---|---|
Account |
acc |
Project_Asset__c |
pas |
Contact |
con |
Project_Skill__c |
prs |
Skill__c |
skl |
Certification__c |
crt |
Experience__c |
exp |
Certification_Skill__c |
cks |
Experience_Highlight__c |
hlt |
Education__c |
edu |
Experience_Skill__c |
exs |
Testimonial__c |
tst |
Project__c |
prj |
- Assignment.
PublicKeyService.assignfills any blank key before insert. It draws a key, checks the draw against stored rows and the rest of the batch with one query, and draws again for each clash, up to five times, then raisesPublicKeyException. A key a record arrives with is kept.PublicKeyService.refuseChangeruns on update and adds an error when an existing key is changed or cleared, with the case compared exactly. Setting a key on a row that had none is allowed, which is how the backfill works. None of the 13 objects had a trigger, so each got one minimal trigger named<Object>PublicKeyTriggerthat does nothing but call the service. - Existing rows.
scripts/backfill-public-keys.apexfills blank keys in batches of 200 and says how many rows remain. I run it once against production after the deploy. Writing to production data is mine. - API. Every SAPI and PAPI object that carried
idalso carrieskey. Every reference carries its own pair:projectKey,experienceKey,skillKey,certificationKey,contactKey,employerKeyandissuerKey. Each query parameter that took an Id (projectId,experienceId,certificationId) and the/projects/{id}path segment now take an Id or a key. A value that is a Salesforce Id is used as before. A value shaped like a key is looked up on that object, and a key with no record matches no rows, the way an Id with no record does. Anything else is a400 BAD_REQUESTon the three parameters. The path keeps its old behaviour for such a value, a404. - Query definitions. The key fields are added to the code defaults and to the matching
Query_Definition__mdtrecords together, so each record still ships equal to its default. - Specs. Both specs move to 1.4.0, gain
keyand the reference keys, and markidand each reference Iddeprecated: truewith a description that points at the key.
Alternatives considered
| Option | Why not |
|---|---|
Replace id with key in v1 |
It breaks every client that stores an Id. I chose the additive path. |
| Use the record Name or a slug | A rename would change the key, and names carry personal detail. Names are not unique on every object. |
Use Auto Number or a sequence |
It tells a reader how many records exist and in what order. It is guessable. |
| Use a UUID | 36 characters is long for a URL, and it carries no hint of the object. The prefix makes a mistyped or misplaced key easy to spot. |
| Fill the key in a Flow | A trigger can check a draw against the stored rows and the batch and redraw in one place, and the same service serves the backfill. |
Add new projectKey style parameters beside the old |
Two parameters for one meaning. The existing parameter takes both forms without a change to any client. |
Implications
- Deprecation, not removal.
idand the reference Ids stay in every v1 response and stay accepted. A client should move tokey. A v2 would removeidandxIdfrom the responses and the schemas, accept only keys in the parameters, make the field required, and drop the Id branch fromSAPITransformer.resolveReference. - Keys are blank until the backfill runs. Between the deploy and the backfill, an old row returns
key: null. The new rows are keyed from their first insert. Run the backfill straight after the deploy. - A key is permanent. Deleting and recreating a record makes a new key. The seed data plans in
data/**create rows through the import, so the trigger keys them. A reseed after a delete gives new keys. - The path
/projects/{id}is not in the spec. The Apex serves it and has always done so. It takes a key now. Adding it to the spec is a separate change. - One more query per insert transaction. The collision check reads the stored keys once per object per attempt. A bulk insert of 251 rows costs the same one query.
- Field access.
Integration_Accessreads the new fields andPortfolio_Adminedits them.Portfolio_Guestis not changed in this decision. - Not part of this decision. The site’s LWC components do not read the API responses and are not changed.