Skip to content

Commit 7fb70a6

Browse files
committed
fix(core): narrow the binding rules to what each one protects
Three rules introduced with the bindings reached past the thing they exist to stop. `_app.appId` was unique as well as immutable, which made an app's second card unwritable. `appId` is required, so a replacement card for the same software necessarily repeats it — and uniqueness then refused exactly the card key rotation calls for, and the same app registered from a second device, with no way out but hard-deleting the first card and orphaning the `principalId` on everything it wrote. Uniqueness bought nothing there: nothing resolves a card *by* `appId`, the cross-check reaching it by `did` and only comparing, so `_app.did` alone already gives that lookup its single answer. Immutability is what stops a card being moved onto a name it did not have, and immutability stays. Uniqueness now covers the two `did` fields — the ones something resolves by — and `UNIQUE_BINDING_FIELDS` names that subset rather than leaving it implied by the immutability list. The `_app` and owner-DID fences keyed on a field being *present* in the patch, not on its value changing. Read-modify-write is the ordinary client shape, so a write-holder sending a card back with `name` edited was refused for round-tripping bindings it never touched — which made "`name` and `version` stay writable" false for any client that patches with a whole content object, and disagreed with `checkBindingImmutable()` and `restoreVersion()`, both of which already compare values. Both fences now ask whether the write would introduce or alter the field. The uniqueness scan materialised the whole family through `queryAllPages()` before testing it, so a family past that helper's cap turned every create and update into a `StackQueryError` rather than a conflict. It now uses `findFirstMatch()`, the same bounded walk the `appId` cross-check added beside it, and stops at the first clash. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017JmFgdZKqgqPFGoxMBgjwn
1 parent 7555861 commit 7fb70a6

4 files changed

Lines changed: 165 additions & 37 deletions

File tree

docs/spec/identity.md

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -91,22 +91,28 @@ Linking the two is the owner's job, not the library's: an `_app` Record with a `
9191

9292
That second rule is what makes "only the owner writes cards" true, rather than nearly true. Type-level grants are refused, but record-level `write` on an individual card is shareable like on any other Record, so a write-holder is a real writer of that card — free to correct its `name` or `version`, and refused the two fields a trust decision reads.
9393

94-
Both fields are also **bindings** in the sense defined next, which is where uniqueness and immutability come from.
94+
**The rule reads values, not keys.** A write that carries `did` or `appId` unchanged is not setting it, so it is not refused: a client that reads a card, edits its `name` and sends the whole content object back is exercising exactly the reach the previous paragraph describes, and `update()` being a merge is a calling convention rather than a permission boundary. Only a write that would introduce or alter one of the two fields needs the owner. The same reading applies to the [reserved owner DID](#did-bindings) below — the refusal is on _claiming_ it — and `restoreVersion()` compares the snapshot against the Record's present content for the same reason.
95+
96+
Both fields are also **bindings** in the sense defined next, which is where immutability — and, for `did`, uniqueness — comes from.
9597

9698
## DID bindings
9799

98100
Two system types carry fields that are **lookup keys rather than display values**: something resolves _through_ them to reach a name the owner chose.
99101

100-
| Field | What resolves through it |
101-
| ------------- | ----------------------------------------------- |
102-
| `_entity.did` | a Record's `entityId` → who authored it |
103-
| `_app.did` | a Record's `principalId` → which app wrote it |
104-
| `_app.appId` | a verified principal → the `appId` it may claim |
102+
| Field | What resolves through it | Unique |
103+
| ------------- | ----------------------------------------------- | ------ |
104+
| `_entity.did` | a Record's `entityId` → who authored it | yes |
105+
| `_app.did` | a Record's `principalId` → which app wrote it | yes |
106+
| `_app.appId` | a verified principal → the `appId` it may claim | no |
107+
108+
A binding is not a value a card happens to hold; it is what makes the card _about_ something. Two rules follow:
109+
110+
- **Immutable once set** — every field in the table. Adopting a value is a one-way step a card carrying none can still take, but a card that holds one never moves off it: `update()` and `restoreVersion()` both refuse to change or clear a binding with `StackValidationError`. This is the rule that stops an existing card being _moved_ onto a value — repointed at another key, or relabelled to claim another app's `appId` — which is impersonation by a quieter route than minting a card.
111+
- **Unique within a stack** — the two `did` fields, the ones a lookup resolves _by_. A second card claiming a value already in use is refused with `StackConflictError`, on create and on update. A soft-deleted card keeps its claim: a deleted card is `undelete()`-able, so releasing the value on delete would let a new card take it and the old one come back beside it. Without uniqueness, "the Record whose `did` matches" has no single answer — and ambiguity is all an impersonating card needs.
105112

106-
A binding is not a value a card happens to hold; it is what makes the card _about_ something. Two rules follow, and they apply to every field in that table:
113+
**`_app.appId` is immutable but not unique**, and the asymmetry is load-bearing. Nothing resolves a card _by_ `appId`: the cross-check finds the card by `content.did` and only compares `appId` to what the principal claimed, so `_app.did` being unique already gives that lookup its single answer. Uniqueness on `appId` would add no disambiguation and would forbid a state the stack has to be able to reach — two cards naming the same software under different keys. `appId` is required, so a card for an app's new key necessarily repeats it, and without the exemption the replacement card that [key rotation](#deferred-key-rotation) calls for could not be written at all, nor the same app registered on a second device. Both cards are the owner's own to write, `_app` being ungrantable; what stays refused is moving an _existing_ card onto an `appId` it did not have.
107114

108-
- **Unique within a stack.** A second card claiming a value already in use is refused with `StackConflictError`, on create and on update. A soft-deleted card keeps its claim: a deleted card is `undelete()`-able, so releasing the value on delete would let a new card take it and the old one come back beside it. Without uniqueness, "the Record whose `did` matches" has no single answer — and ambiguity is all an impersonating card needs.
109-
- **Immutable once set.** Uniqueness stops a second card claiming a value; only immutability stops an existing card being _moved_ onto one, which reaches the same impersonation by another route. `update()` and `restoreVersion()` both refuse to change or clear a binding with `StackValidationError`. Adopting a value is a one-way step a card carrying none can still take. A subject whose key changes gets a new card, matching this document's deferral of [key rotation](#deferred-key-rotation) — a new key is a new identity, not the same one relabelled.
115+
A subject whose key changes therefore gets a new card, matching this document's deferral of [key rotation](#deferred-key-rotation) — a new key is a new identity, not the same one relabelled. Records written under the old key keep pointing at the old card, which is what makes their attribution still resolvable.
110116

111117
**`_entity` stays grantable; `_app` does not.** Naming people is ordinary app work — a contacts app creates and relabels cards — so `_entity` cards are reachable by grant and only the two binding rules fence them. Naming software is a trust decision about who may speak as what, so `_app` adds the owner-only rule above. The asymmetry is deliberate: both registries resolve a name, but only one of them is deciding whether to believe a claim.
112118

@@ -175,4 +181,4 @@ Token issuance (see [Wire format § Authentication](./wire-format.md#authenticat
175181

176182
## Deferred: key rotation
177183

178-
With pure `did:key`, identity _is_ the key: lose it and you're a new identity. For individuals and small groups who know each other, that's a recoverable social event ("new key, it's me" over a trusted channel; contacts update their `_entity` cards), not a protocol failure. Rotation — a signed chain of "key A hands off to key B" records, hosted by the stack itself — is a native fit for a future RFC, but nothing here blocks it: a rotated identity is either a new DID _documented by_ that log, or a method upgrade (`did:key` → stack-hosted method) for those who opt in. Multi-device works without rotation in the meantime: the identity key bootstraps a session per device via challenge–response; devices hold revocable tokens, never the key.
184+
With pure `did:key`, identity _is_ the key: lose it and you're a new identity. For individuals and small groups who know each other, that's a recoverable social event ("new key, it's me" over a trusted channel; contacts card the new key), not a protocol failure. Carding it is a new `_entity` record rather than an edit to the old one — [`did` is immutable once set](#did-bindings), which is the same rule that lets an app's replacement card carry the `appId` its predecessor already claimed. Rotation — a signed chain of "key A hands off to key B" records, hosted by the stack itself — is a native fit for a future RFC, but nothing here blocks it: a rotated identity is either a new DID _documented by_ that log, or a method upgrade (`did:key` → stack-hosted method) for those who opt in. Multi-device works without rotation in the meantime: the identity key bootstraps a session per device via challenge–response; devices hold revocable tokens, never the key.

packages/core/src/stack.ts

Lines changed: 67 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -84,17 +84,40 @@ const UNGRANTABLE_SYSTEM_TYPES: ReadonlySet<string> = new Set([
8484

8585
/**
8686
* Content fields that are lookup keys rather than display values: a card
87-
* claims one, and something later resolves through it. Each is unique per
88-
* stack and immutable once set. See docs/spec/identity.md § DID bindings.
87+
* claims one, and something later resolves through it. Every one of them is
88+
* immutable once set. See docs/spec/identity.md § DID bindings.
8989
*/
9090
const BINDING_FIELDS: ReadonlyMap<string, readonly ('did' | 'appId')[]> = new Map([
9191
[SYSTEM_TYPES.APP, ['did', 'appId'] as const],
9292
[SYSTEM_TYPES.ENTITY, ['did'] as const],
9393
]);
9494

95+
/**
96+
* The subset that is additionally unique per stack: the fields something
97+
* resolves *by*. A Record's `principalId` finds its card by `_app.did` and
98+
* its `entityId` by `_entity.did`, so a second card claiming either leaves
99+
* that lookup without a single answer — and ambiguity is all an
100+
* impersonating card needs.
101+
*
102+
* `_app.appId` is deliberately absent. Nothing resolves a card by it — the
103+
* cross-check reaches the card by `did` and only compares `appId` — so
104+
* uniqueness would buy no disambiguation, while forbidding the second card
105+
* key rotation is supposed to produce: `appId` is required, so a
106+
* replacement card for the same software necessarily repeats it. Moving one
107+
* card onto another's `appId` is what immutability already refuses.
108+
* See docs/spec/identity.md § DID bindings.
109+
*/
110+
const UNIQUE_BINDING_FIELDS: ReadonlyMap<string, readonly ('did' | 'appId')[]> = new Map([
111+
[SYSTEM_TYPES.APP, ['did'] as const],
112+
[SYSTEM_TYPES.ENTITY, ['did'] as const],
113+
]);
114+
95115
const bindingFieldsOf = (family: string): readonly ('did' | 'appId')[] =>
96116
BINDING_FIELDS.get(family) ?? [];
97117

118+
const uniqueBindingFieldsOf = (family: string): readonly ('did' | 'appId')[] =>
119+
UNIQUE_BINDING_FIELDS.get(family) ?? [];
120+
98121
export type CreateRecordOptions = {
99122
/**
100123
* Client-minted record ID. Must be 12 lowercase Crockford base-32
@@ -1297,21 +1320,22 @@ export class Stack implements StackClient {
12971320
});
12981321
}
12991322

1300-
/** Uniqueness for every binding field a newly created card claims. */
1323+
/** Uniqueness for every unique binding field a newly created card claims. */
13011324
private async checkBindingsOnCreate(
13021325
typeId: TypeId,
13031326
content: Record<string, unknown>,
13041327
): Promise<void> {
13051328
const family = baseIdOf(typeId);
1306-
for (const field of bindingFieldsOf(family)) {
1329+
for (const field of uniqueBindingFieldsOf(family)) {
13071330
await this.checkBindingUnique(family, field, content[field]);
13081331
}
13091332
}
13101333

13111334
/**
1312-
* Immutability then uniqueness for every binding field a patch touches.
1313-
* Fields absent from the patch carry no new claim — update() is a merge,
1314-
* so an untouched binding is the one the card already holds.
1335+
* Immutability for every binding field a patch touches, then uniqueness
1336+
* for the subset that carries it. Fields absent from the patch carry no
1337+
* new claim — update() is a merge, so an untouched binding is the one the
1338+
* card already holds.
13151339
*/
13161340
private async checkBindingsOnUpdate(
13171341
typeId: TypeId,
@@ -1321,21 +1345,27 @@ export class Stack implements StackClient {
13211345
merged: Record<string, unknown>,
13221346
): Promise<void> {
13231347
const family = baseIdOf(typeId);
1348+
const unique = uniqueBindingFieldsOf(family);
13241349
for (const field of bindingFieldsOf(family)) {
13251350
if (!(field in patch)) continue;
13261351
this.checkBindingImmutable(family, field, existing[field], merged[field]);
1327-
await this.checkBindingUnique(family, field, merged[field], id);
1352+
if (unique.includes(field)) {
1353+
await this.checkBindingUnique(family, field, merged[field], id);
1354+
}
13281355
}
13291356
}
13301357

13311358
/**
1332-
* A card's binding field is what a lookup resolves through — a record's
1333-
* `principalId` through `_app.did`, its `entityId` through `_entity.did`,
1334-
* a claimed `appId` through `_app.appId`. Two cards claiming one value
1335-
* would make that lookup ambiguous, and ambiguity is all an impersonating
1336-
* card needs. Enforced here rather than by schema, since uniqueness is a
1337-
* property of the set, not of the value. Called by the paths that can
1338-
* introduce a binding: create and update.
1359+
* A unique binding field is what a lookup resolves *by* — a record's
1360+
* `principalId` by `_app.did`, its `entityId` by `_entity.did`. Two cards
1361+
* claiming one value would leave that lookup without a single answer, and
1362+
* ambiguity is all an impersonating card needs. Enforced here rather than
1363+
* by schema, since uniqueness is a property of the set, not of the value.
1364+
* Called by the paths that can introduce a binding: create and update.
1365+
*
1366+
* Short-circuits on the first clash rather than materialising the family,
1367+
* so a stack whose `_entity` family is larger than a single scan settles
1368+
* the common case — the value is already taken — without walking the rest.
13391369
*
13401370
* Read-then-write, so two creates racing on one value can both pass.
13411371
* Closing that properly means a unique index over a JSON field, which
@@ -1351,14 +1381,15 @@ export class Stack implements StackClient {
13511381
): Promise<void> {
13521382
if (typeof value !== 'string' || value === '') return;
13531383

1354-
const results = await queryAllPages((q) => this.query(q), {
1355-
filter: {
1356-
baseId: family,
1357-
includeDeleted: true,
1358-
...(this.features.contentFieldQuery && { content: { [field]: value } }),
1384+
const clash = await findFirstMatch(
1385+
(q) => this.query(q),
1386+
{
1387+
filter: {
1388+
baseId: family,
1389+
includeDeleted: true,
1390+
...(this.features.contentFieldQuery && { content: { [field]: value } }),
1391+
},
13591392
},
1360-
});
1361-
const clash = results.some(
13621393
(r) => r.id !== excludeId && (r.content as Record<string, unknown>)[field] === value,
13631394
);
13641395
if (clash) {
@@ -2483,8 +2514,20 @@ export class ScopedStack implements StackClient {
24832514
opts: IfVersionOptions = {},
24842515
): Promise<StackRecord> {
24852516
const record = await this.requireUpdatable(id);
2486-
this.requireOwnerForAppIdentity(record.typeId, (field) => field in content);
2487-
if ('did' in content) this.requireOwnerForOwnerDid(record.typeId, content.did);
2517+
// Value-wise, not presence-wise: a client that reads a card, edits its
2518+
// `name` and sends the whole content object back is not setting the
2519+
// binding it round-trips, and `name` is writable by record-level
2520+
// permission. Same predicate restoreVersion() applies to a snapshot.
2521+
this.requireOwnerForAppIdentity(
2522+
record.typeId,
2523+
(field) =>
2524+
field in content && content[field] !== (record.content as Record<string, unknown>)[field],
2525+
);
2526+
// Likewise value-wise: re-sending the DID a card already holds claims
2527+
// nothing, and immutability refuses changing it regardless.
2528+
if ('did' in content && content.did !== (record.content as Record<string, unknown>).did) {
2529+
this.requireOwnerForOwnerDid(record.typeId, content.did);
2530+
}
24882531
await this.requireFileRefAccess(record.typeId, content);
24892532
return this.stack.update(id, content, opts);
24902533
}

packages/core/tests/scoped-stack.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2344,6 +2344,32 @@ describe('ScopedStack — _app bindings', () => {
23442344
const updated = await stack.asEntity(MEMBER).update(shared.id, { version: '2.0.0' });
23452345
expect((updated.content as { did?: string }).did).toBe(APP_DID);
23462346
});
2347+
2348+
// The fence reads values, not keys. Read-modify-write is the ordinary
2349+
// client shape, and a card sent back with its bindings untouched is
2350+
// exercising the display-field reach the test above pins — a presence
2351+
// check would make that reach unreachable for any client that round-trips
2352+
// the whole content object.
2353+
test('a write-holder may round-trip unchanged bindings in a full-content update', async () => {
2354+
const shared = await stack.create('_app@1', {
2355+
appId: 'com.example.notes',
2356+
name: 'My Notes App',
2357+
did: APP_DID,
2358+
});
2359+
await stack.setPermissions(shared.id, [
2360+
{ access: 'entity', entityId: MEMBER, read: true, write: true },
2361+
]);
2362+
2363+
const scoped = stack.asEntity(MEMBER);
2364+
const current = await scoped.get(shared.id);
2365+
const updated = await scoped.update(shared.id, { ...current!.content, name: 'Renamed' });
2366+
2367+
expect(updated.content).toMatchObject({
2368+
appId: 'com.example.notes',
2369+
name: 'Renamed',
2370+
did: APP_DID,
2371+
});
2372+
});
23472373
});
23482374

23492375
// The owner-only rule covers both halves of the attribution lookup: a
@@ -2443,6 +2469,21 @@ describe('ScopedStack — _entity bindings hold under a grant', () => {
24432469
.create('_entity@1', { did: OWNER, name: 'Owner, per the app' }),
24442470
).rejects.toThrow(StackPermissionError);
24452471
});
2472+
2473+
// The reservation is on *claiming* the DID. Re-sending the value a card
2474+
// already holds claims nothing, and immutability refuses changing it — so
2475+
// refusing the round-trip would only stop a grantee relabelling the card,
2476+
// which is the reach a grant on _entity exists to give.
2477+
test('a grantee may relabel the owner card while round-tripping its DID', async () => {
2478+
const ownerCard = await stack.asEntity(OWNER).create('_entity@1', { did: OWNER, name: 'Me' });
2479+
await stack.grant(MEMBER, [{ typeId: '_entity@1', actions: ['read-any', 'update-any'] }]);
2480+
2481+
const scoped = stack.asEntity(MEMBER);
2482+
const current = await scoped.get(ownerCard.id);
2483+
const updated = await scoped.update(ownerCard.id, { ...current!.content, name: 'The Owner' });
2484+
2485+
expect(updated.content).toMatchObject({ did: OWNER, name: 'The Owner' });
2486+
});
24462487
});
24472488

24482489
describe('ScopedStack — _grant records are owner-write-only', () => {

packages/core/tests/stack.test.ts

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2994,10 +2994,31 @@ describe('_app.did bindings', () => {
29942994
describe('_app.appId bindings', () => {
29952995
const APP_DID = 'did:key:z6MkNotesApp';
29962996

2997-
test('rejects a second card claiming an appId already in use', async () => {
2998-
await stack.create('_app@1', { appId: 'com.example.notes', name: 'My Notes App' });
2997+
// appId is immutable but not unique: nothing resolves a card by it, and
2998+
// requiring it while forbidding a second card holding it would make the
2999+
// replacement card key rotation calls for unwritable.
3000+
test('a second card may claim the same appId under a different did', async () => {
3001+
await stack.create('_app@1', {
3002+
appId: 'com.example.notes',
3003+
name: 'My Notes App',
3004+
did: APP_DID,
3005+
});
3006+
const rotated = await stack.create('_app@1', {
3007+
appId: 'com.example.notes',
3008+
name: 'My Notes App',
3009+
did: 'did:key:z6MkNotesAppRotated',
3010+
});
3011+
expect((rotated.content as { appId: string }).appId).toBe('com.example.notes');
3012+
});
3013+
3014+
test('the did on those cards is still unique', async () => {
3015+
await stack.create('_app@1', {
3016+
appId: 'com.example.notes',
3017+
name: 'My Notes App',
3018+
did: APP_DID,
3019+
});
29993020
await expect(
3000-
stack.create('_app@1', { appId: 'com.example.notes', name: 'Impostor' }),
3021+
stack.create('_app@1', { appId: 'com.example.other', name: 'Impostor', did: APP_DID }),
30013022
).rejects.toThrow(StackConflictError);
30023023
});
30033024

@@ -3068,6 +3089,23 @@ describe('_entity.did bindings', () => {
30683089
const restored = await stack.restoreVersion(alice.id, 1);
30693090
expect((restored.content as { did: string }).did).toBe(ALICE);
30703091
});
3092+
3093+
// Without contentFieldQuery the check cursor-walks the family and stops at
3094+
// the first clash, so a colliding card past page one must still be found —
3095+
// short-circuiting is what keeps the walk bounded, not a narrower scan.
3096+
test('finds a clash past page one on an adapter without contentFieldQuery', async () => {
3097+
const incapable = await Stack.create(
3098+
new IncapableMemoryAdapter({ ownerEntityId: 'owner-123', timezone: 'UTC' }),
3099+
);
3100+
for (let i = 0; i < 60; i++) {
3101+
await incapable.create('_entity@1', { did: `did:key:filler${i}`, name: `Filler ${i}` });
3102+
}
3103+
await incapable.create('_entity@1', { did: ALICE, name: 'Alice' });
3104+
3105+
await expect(
3106+
incapable.create('_entity@1', { did: ALICE, name: 'Alice (impostor)' }),
3107+
).rejects.toThrow(StackConflictError);
3108+
});
30713109
});
30723110

30733111
// grant() refuses these families, but a _grant record is an ordinary Record

0 commit comments

Comments
 (0)