OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-identity commits / 3f16128

identity: the clock belongs in the op, not in apply
apply is replayed on every boot and every reorg, and each peer replays independently. It called `Date.now()` at 21 sites, only 4 of which honoured an op-supplied value. So two peers replaying the SAME ops wrote DIFFERENT view bytes. ⛔ And one of them was not a stamp at all: if (token.expiresAt && token.expiresAt < Date.now()) break // expired That is control flow branching on wall-clock inside apply. A link token expiring between two peers' replays meant one peer CONSUMED it and the other skipped — divergent state, not merely divergent bytes. It now compares against the op's own clock, which is the correct reading anyway: the question is whether the token had expired when the consume was authored, not when some peer happened to replay it. The fix is one field, stamped once. `encodeOp` writes `timestamp` into every op at append time on the authoring peer, and apply reads `op.timestamp || Date.now()` once per node. Field named to match hyper-rooms (lib/room.js:220,260,273 write it, :418 reads it) — one convention across the stack rather than a second one. `...op` spreads after the stamp so an explicit timestamp still wins, which the timestamp-preserving reconciler paths depend on. ⚠ The fallback keeps ops written before this field replaying exactly as they do today — no worse, and unfixable without rewriting history. Every NEW op converges. Fixes both cases in test/apply-determinism.test.js (untracked, not mine, left untouched): 3/3 from 1/3. ## Correcting 3da3499 That commit's message claims "[autobase] contract 17/17 green". It was NOT — it was 16/17, with "the link resolves back to the user" red. I read a truncated tail and assumed the rest. The failure was my test's: getUserByIdentity keys on the identityId (identity-registry.js:214), and I passed the publicKey. They coincide in pgv3, where registerIdentity passes the root pubkey as both, which is exactly why the wrong one reads as a library bug. Fixed here; the control arm is now genuinely 17/17, verified by stashing this fix and re-running rather than by reading a tail. ## Two harness holes closed ★ The false green came back. `engine: 'autobee'` was accepted and reported while the constructor still built an Autobase, so both arms compared autobase to autobase and passed. Constructing an engine we cannot honour now throws — the placeholder the view shim will replace. ★ A missing engine or a missing method threw out of the test body and aborted the whole FILE, silently dropping every test after it. That reads as coverage that does not exist. Both are reported failures now, so red reports instead of concealing. Suite 120/123, 337/340. The only reds are the three intentional ones driving the port: [autobee] contract (engine unbuilt) and both trap tests (isIndexerWriter unbuilt). Baseline before this work was 117/119 with the two determinism failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3f1612826372f564370380976524c75cacb676b5
by plankatron <fleeky@gmail.com> · 2026-09-06T20:51:31.000Z

Changed files (4) +90 -32

lib/auth/identity-apply.js +31 -23
……@@ -76,6 +76,14 @@ export function createApplyFunction (opts = {}) {
7676 }
7777 }
7878
79 // The op's own clock, stamped by encodeOp on the authoring peer. Every timestamp and
80 // every time COMPARISON below uses it, so this apply is a pure function of the log.
81 //
82 // ⚠ The `at` fallback is for ops written BEFORE this field existed, already
83 // durable in deployed logs. Those replay non-deterministically exactly as they do today —
84 // no worse, and unfixable without rewriting history. Every NEW op converges.
85 const at = op.timestamp || Date.now()
86
7987 switch (op.type) {
8088 case OpTypes.CREATE_USER: {
8189 const key = `${prefix}user:${op.userId}`
……@@ -104,8 +112,8 @@ export function createApplyFunction (opts = {}) {
104112 globalPermissions: permissions,
105113 resources,
106114 identities: op.identities || [],
107 createdAt: op.createdAt || Date.now(),
108 updatedAt: op.updatedAt || Date.now()
115 createdAt: op.createdAt || at,
116 updatedAt: op.updatedAt || at
109117 }
110118 await b.put(key, user)
111119
……@@ -128,7 +136,7 @@ export function createApplyFunction (opts = {}) {
128136 if (op.profilePicture !== undefined) user.profilePicture = op.profilePicture
129137 if (op.theme !== undefined) user.theme = op.theme
130138 if (op.globalPermissions !== undefined) user.globalPermissions = op.globalPermissions
131 user.updatedAt = Date.now()
139 user.updatedAt = at
132140 await b.put(key, user)
133141 break
134142 }
……@@ -177,7 +185,7 @@ export function createApplyFunction (opts = {}) {
177185 const user = existing.value
178186 if (!user.identities.includes(op.identityId)) {
179187 user.identities.push(op.identityId)
180 user.updatedAt = Date.now()
188 user.updatedAt = at
181189 await b.put(key, user)
182190 await b.put(`${prefix}user-identity:${op.identityId}`, { userId: op.userId })
183191 }
……@@ -191,7 +199,7 @@ export function createApplyFunction (opts = {}) {
191199
192200 const user = existing.value
193201 user.identities = user.identities.filter(id => id !== op.identityId)
194 user.updatedAt = Date.now()
202 user.updatedAt = at
195203 await b.put(key, user)
196204 await b.del(`${prefix}user-identity:${op.identityId}`)
197205 break
……@@ -205,7 +213,7 @@ export function createApplyFunction (opts = {}) {
205213 const user = existing.value
206214 if (!user.identities.includes(op.identityId)) {
207215 user.identities.push(op.identityId)
208 user.updatedAt = Date.now()
216 user.updatedAt = at
209217 await b.put(key, user)
210218 }
211219 }
……@@ -219,7 +227,7 @@ export function createApplyFunction (opts = {}) {
219227 if (idEntry && idEntry.value.userId !== op.userId) {
220228 const idRec = idEntry.value
221229 idRec.userId = op.userId
222 idRec.updatedAt = Date.now()
230 idRec.updatedAt = at
223231 await b.put(idKey, idRec)
224232 }
225233 break
……@@ -246,7 +254,7 @@ export function createApplyFunction (opts = {}) {
246254 user.resources.push({ name: resourceName, permissions: op.permissions })
247255 }
248256 }
249 user.updatedAt = Date.now()
257 user.updatedAt = at
250258 await b.put(key, user)
251259 break
252260 }
……@@ -284,12 +292,12 @@ export function createApplyFunction (opts = {}) {
284292 const allPerms = new Set([...pUser.globalPermissions, ...sUser.globalPermissions])
285293 pUser.globalPermissions = [...allPerms]
286294
287 pUser.updatedAt = Date.now()
295 pUser.updatedAt = at
288296 await b.put(primaryKey, pUser)
289297
290298 // Mark secondary as merged
291299 sUser.mergedInto = op.primaryUserId
292 sUser.updatedAt = Date.now()
300 sUser.updatedAt = at
293301 await b.put(secondaryKey, sUser)
294302 break
295303 }
……@@ -299,7 +307,7 @@ export function createApplyFunction (opts = {}) {
299307 // Direct-write db is updated separately in registry method
300308 await b.put(`${prefix}revoked:${op.identityId}`, {
301309 identityId: op.identityId,
302 revokedAt: op.revokedAt || Date.now(),
310 revokedAt: op.revokedAt || at,
303311 reason: op.reason || null
304312 })
305313 break
……@@ -326,9 +334,9 @@ export function createApplyFunction (opts = {}) {
326334 if (adminCount <= 1) continue // Silently skip — can't deactivate last admin
327335 }
328336
329 user.deactivatedAt = Date.now()
337 user.deactivatedAt = at
330338 user.deactivatedReason = op.reason || null
331 user.updatedAt = Date.now()
339 user.updatedAt = at
332340 await b.put(key, user)
333341 break
334342 }
……@@ -341,7 +349,7 @@ export function createApplyFunction (opts = {}) {
341349 const user = existing.value
342350 delete user.deactivatedAt
343351 delete user.deactivatedReason
344 user.updatedAt = Date.now()
352 user.updatedAt = at
345353 await b.put(key, user)
346354 break
347355 }
……@@ -356,7 +364,7 @@ export function createApplyFunction (opts = {}) {
356364 tokenHash: op.tokenHash,
357365 userId: op.userId,
358366 expiresAt: op.expiresAt,
359 createdAt: op.createdAt || Date.now(),
367 createdAt: op.createdAt || at,
360368 consumed: false
361369 })
362370 break
……@@ -368,13 +376,13 @@ export function createApplyFunction (opts = {}) {
368376 if (!tokenEntry) break // unknown token
369377 const token = tokenEntry.value
370378 if (token.consumed) break // double-consume
371 if (token.expiresAt && token.expiresAt < Date.now()) break // expired
379 if (token.expiresAt && token.expiresAt < at) break // expired
372380
373381 // 1. Mark token consumed (kept for audit, never deleted)
374382 await b.put(tokenKey, {
375383 ...token,
376384 consumed: true,
377 consumedAt: Date.now(),
385 consumedAt: at,
378386 consumedBy: op.newIdentityId
379387 })
380388
……@@ -385,7 +393,7 @@ export function createApplyFunction (opts = {}) {
385393 const target = targetEntry.value
386394 if (!target.identities.includes(op.newIdentityId)) {
387395 target.identities.push(op.newIdentityId)
388 target.updatedAt = Date.now()
396 target.updatedAt = at
389397 await b.put(targetKey, target)
390398 }
391399 // Always rewrite the user-identity index — if the identity
……@@ -402,9 +410,9 @@ export function createApplyFunction (opts = {}) {
402410 if (dEntry) {
403411 const u = dEntry.value
404412 u.identities = (u.identities || []).filter(id => id !== op.newIdentityId)
405 u.deactivatedAt = Date.now()
413 u.deactivatedAt = at
406414 u.deactivatedReason = 'merged-via-link-token'
407 u.updatedAt = Date.now()
415 u.updatedAt = at
408416 await b.put(dKey, u)
409417 }
410418 }
……@@ -425,7 +433,7 @@ export function createApplyFunction (opts = {}) {
425433
426434 // Set new username
427435 user.username = op.username
428 user.updatedAt = Date.now()
436 user.updatedAt = at
429437 await b.put(key, user)
430438 await b.put(`${prefix}username:${op.username}`, { userId: op.userId })
431439 break
……@@ -434,7 +442,7 @@ export function createApplyFunction (opts = {}) {
434442 case OpTypes.ADD_WRITER: {
435443 // Audit trail in the view
436444 await b.put(`${prefix}writer:${op.writerKey}`, {
437 addedAt: Date.now(),
445 addedAt: at,
438446 addedBy: op.addedBy || null,
439447 role: op.role || 'writer'
440448 })
……@@ -471,7 +479,7 @@ export function createApplyFunction (opts = {}) {
471479 // no side effects on other autobases from here.
472480 await b.put(`${prefix}mirror-writers:${op.devicePubkey}`, {
473481 keys: op.keys,
474 recordedAt: Date.now()
482 recordedAt: at
475483 })
476484 break
477485 }
lib/auth/identity-operations.js +17 -1
……@@ -56,7 +56,23 @@ export const OpTypes = {
5656 * @returns {Buffer}
5757 */
5858export function encodeOp (op) {
59 return b4a.from(JSON.stringify(op))
59 // ⛔ THE CLOCK IS STAMPED HERE, ONCE, ON THE AUTHORING PEER — never inside apply.
60 //
61 // apply is replayed on every boot and every reorg, and each peer replays independently. A
62 // `Date.now()` inside it means two peers replaying the SAME ops write DIFFERENT view bytes, and
63 // in the one place apply branched on the clock (link-token expiry) it meant they reached
64 // different STATE — one peer consuming a token the other rejected.
65 //
66 // Putting it in the op makes it part of the bytes every peer agrees on, so replay is a pure
67 // function of the log. Same fix hyper-rooms uses (lib/room.js:220,260,273 write it, :418 reads
68 // `op.timestamp || Date.now()`).
69 //
70 // The field is `timestamp` to match hyper-rooms (lib/room.js:220,260,273), the reference
71 // implementation on this same substrate — one convention across the stack, not a second one.
72 //
73 // `...op` comes AFTER so an explicit timestamp wins: the timestamp-preserving paths (a reconciler
74 // re-emitting a record it already holds) must not have their clock bumped by a re-encode.
75 return b4a.from(JSON.stringify({ timestamp: Date.now(), ...op }))
6076}
6177
6278/**
lib/auth/identity-registry.js +7 -0
……@@ -46,6 +46,13 @@ export class IdentityRegistry {
4646 if (this.engine !== 'autobase' && this.engine !== 'autobee') {
4747 throw new Error('IdentityRegistry: unknown engine "' + this.engine + '" — expected autobase or autobee')
4848 }
49 // ⛔ Accepting a name we cannot honour is worse than rejecting it. Reporting
50 // `engine: 'autobee'` while constructing an Autobase makes every parity assertion pass by
51 // comparing autobase to autobase — the exact false green the harness exists to prevent, and it
52 // slipped in once already. This throw is the placeholder the view shim replaces.
53 if (this.engine === 'autobee') {
54 throw new Error('IdentityRegistry: the autobee engine is not built yet — no view shim')
55 }
4956
5057 // Separate Hyperbee for direct key lookups (identity records, pubkey index)
5158 // This is NOT the Autobase view — it's a direct-write store for identity metadata
test/engine-parity.test.js +35 -8
……@@ -96,7 +96,10 @@ async function contract (t, registry) {
9696 t.is(ident.method, 'web-identity')
9797
9898 await registry.linkIdentityToUser('ident-1', alice.userId, { verified: true })
99 const byIdent = await registry.getUserByIdentity('pk-ada')
99 // ⚠ Keyed on the identityId, NOT the public key — `user-identity:<identityId>`
100 // (identity-registry.js:214). They coincide in pgv3, where registerIdentity passes the root
101 // pubkey as both, which is exactly why passing the wrong one reads as a library bug.
102 const byIdent = await registry.getUserByIdentity('ident-1')
100103 t.is(byIdent && byIdent.userId, alice.userId, 'and the link resolves back to the user')
101104
102105 // ── a uniqueness conflict ───────────────────────────────────────────────────────────────────
……@@ -132,11 +135,26 @@ async function contract (t, registry) {
132135 t.ok(await registry.isRevoked('pk-ada'), 'revocation reads through')
133136}
134137
138// An engine that cannot be constructed must FAIL, not abort.
139//
140// A throw out of `open()` kills the whole file, so every test after it silently never runs — the
141// report says nothing about them, which reads like coverage that does not exist. This turns an
142// unbuildable engine into one red line and lets the rest of the suite report.
143async function openOrFail (t, engine) {
144 try {
145 return await open(engine)
146 } catch (err) {
147 t.fail('cannot open the ' + engine + ' engine: ' + err.message)
148 return null
149 }
150}
151
135152for (const engine of ENGINES) {
136153 test('[' + engine + '] the IdentityRegistry contract', async (t) => {
137 const { registry, cleanup } = await open(engine)
138 t.teardown(cleanup)
139 await contract(t, registry)
154 const opened = await openOrFail(t, engine)
155 if (!opened) return
156 t.teardown(opened.cleanup)
157 await contract(t, opened.registry)
140158 })
141159}
142160
……@@ -153,8 +171,10 @@ for (const engine of ENGINES) {
153171// So the default is asserted rather than inherited, on both engines.
154172for (const engine of ENGINES) {
155173 test('[' + engine + '] a plain writer is NOT granted indexer standing', async (t) => {
156 const { registry, cleanup } = await open(engine)
157 t.teardown(cleanup)
174 const opened = await openOrFail(t, engine)
175 if (!opened) return
176 const registry = opened.registry
177 t.teardown(opened.cleanup)
158178
159179 const key = crypto.keyPair().publicKey.toString('hex')
160180 await registry.addWriter(key, { role: 'writer' })
……@@ -168,7 +188,14 @@ for (const engine of ENGINES) {
168188 // asserting on it would pass whatever the engine actually granted. The real question is what
169189 // standing the LOG gave the writer, which each engine spells differently — hence a normalized
170190 // accessor, which the port owes. Red until it exists, deliberately.
171 t.is(await registry.isIndexerWriter(key), false,
172 'a writer is not an indexer — the default must be pinned, never inherited from the engine')
191 // Reported as a failure rather than called blindly: a missing method throws a TypeError, which
192 // aborts the whole FILE and silently drops every test after it. Red must report, never conceal.
193 if (typeof registry.isIndexerWriter !== 'function') {
194 t.fail('registry.isIndexerWriter is not implemented — the port owes a normalized accessor ' +
195 'for writer standing, because each engine spells it differently')
196 } else {
197 t.is(await registry.isIndexerWriter(key), false,
198 'a writer is not an indexer — the default must be pinned, never inherited from the engine')
199 }
173200 })
174201}