OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-issues commits / 8700973

P4a: prove writer-set membership is the ref push authority
gip-overlay P4 — a node may advance refs iff its writer key is in the per-repo autobase's writer set. Grant = addWriter (repo invite/join), revoke = removeWriter (both already on HyperIssues). Test the authority matrix at the autobase level (deterministic, replicateAndSync): - forgery: a non-writer's update-ref never reaches the canonical view - grant: after addWriter, the new writer's advance is accepted - revoke: after removeWriter, the removed node's later op is NOT applied, and it can still READ (reads are open) - the remaining writer keeps write authority Suite 27/27, 93 asserts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
8700973567a611240b0727592d9424ce164db43b
by plankatron <fleeky@gmail.com> · 2026-05-29T17:49:01.000Z

Changed files (1) +46 -0

test/basic.test.js +46 -0
……@@ -420,6 +420,52 @@ test('two writers can both append after addWriter', async (t) => {
420420 })
421421})
422422
423// ── gip-overlay P4: writer-set membership IS the ref push authority ──
424// A node may advance refs iff its writer key is in the per-repo autobase's
425// writer set. Grant = addWriter (repo invite/join); revoke = removeWriter.
426// A non-writer's op never reaches the linearized view (forgery rejected);
427// reads are open regardless.
428
429test('refs authority: writer-set gates ref advances (forgery / grant / revoke)', async (t) => {
430 const storeA = tmpStore()
431 const storeB = tmpStore()
432 const A = new HyperIssues(storeA, { namespace: 'gip-auth' })
433 await A.ready()
434 const B = new HyperIssues(storeB, { namespace: 'gip-auth', bootstrapKey: A.keyHex })
435 await B.ready()
436 t.teardown(async () => {
437 for (const x of [A, B, storeA, storeB]) { try { await x.close() } catch {} }
438 })
439 await replicateAndSync([A.base, B.base])
440
441 // 1. forgery: B is NOT a writer — its ref op must not reach A's view
442 t.absent(B.writable, 'B is not a writer yet')
443 try { await B.updateRef('refs/heads/main', 'aaa', 'bob') } catch {}
444 await replicateAndSync([A.base, B.base])
445 t.is(await A.getRef('refs/heads/main'), null, 'non-writer ref op rejected (forgery)')
446
447 // 2. grant: addWriter B → B advances the ref, A sees it
448 await A.addWriter('bob', { writerKey: B.localKeyHex, role: 'writer' })
449 await replicateAndSync([A.base, B.base])
450 t.ok(B.writable, 'B writable after grant')
451 await B.updateRef('refs/heads/main', 'bbb', 'bob')
452 await replicateAndSync([A.base, B.base])
453 t.is((await A.getRef('refs/heads/main')).hash, 'bbb', 'granted writer advance accepted')
454
455 // 3. revoke: removeWriter B → B's later op is not applied; reads still work
456 await A.removeWriter('bob')
457 await replicateAndSync([A.base, B.base])
458 try { await B.updateRef('refs/heads/main', 'ccc', 'bob', 'bbb') } catch {}
459 await replicateAndSync([A.base, B.base])
460 t.is((await A.getRef('refs/heads/main')).hash, 'bbb', 'post-revoke op not applied — ref unchanged')
461 t.is((await B.getRef('refs/heads/main')).hash, 'bbb', 'revoked node can still READ')
462
463 // 4. remaining writer (A) keeps write authority
464 await A.updateRef('refs/heads/main', 'ddd', 'alice', 'bbb')
465 await replicateAndSync([A.base, B.base])
466 t.is((await A.getRef('refs/heads/main')).hash, 'ddd', 'owner still advances refs after revoke')
467})
468
423469test('createWithId preserves id, status, and timestamps (migration path)', async (t) => {
424470 const tracker = await makeTracker(t, 'migration-1')
425471