diff --git a/packages/core/src/generated/records.ts b/packages/core/src/generated/records.ts index f1cfeeb..daa9f9e 100644 --- a/packages/core/src/generated/records.ts +++ b/packages/core/src/generated/records.ts @@ -25,6 +25,7 @@ export const COLLECTIONS = { message: "com.disnetdev.radial.message", project: "com.disnetdev.radial.project", removeMember: "com.disnetdev.radial.removeMember", + retractRequest: "com.disnetdev.radial.retractRequest", review: "com.disnetdev.radial.review", space: "com.disnetdev.radial.space", } as const @@ -161,6 +162,12 @@ export interface RemoveMemberRecord { createdAt: string } +export interface RetractRequestRecord { + $type: "com.disnetdev.radial.retractRequest" + request: StrongRef + createdAt: string +} + export type ReviewFinding = { path?: string; line?: number; severity: "info" | "warning" | "error"; body: string } export interface ReviewRecord { @@ -192,6 +199,7 @@ export interface RecordByCollection { [COLLECTIONS.message]: MessageRecord [COLLECTIONS.project]: ProjectRecord [COLLECTIONS.removeMember]: RemoveMemberRecord + [COLLECTIONS.retractRequest]: RetractRequestRecord [COLLECTIONS.review]: ReviewRecord [COLLECTIONS.space]: SpaceRecord } @@ -881,6 +889,33 @@ export const lexiconSchemas = [ } } }, + { + "lexicon": 1, + "id": "com.disnetdev.radial.retractRequest", + "defs": { + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "required": [ + "request", + "createdAt" + ], + "properties": { + "request": { + "type": "ref", + "ref": "com.atproto.repo.strongRef" + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + } + } + } + }, { "lexicon": 1, "id": "com.disnetdev.radial.review", diff --git a/packages/core/src/materializer.ts b/packages/core/src/materializer.ts index 3adc0a8..f8f31df 100644 --- a/packages/core/src/materializer.ts +++ b/packages/core/src/materializer.ts @@ -12,6 +12,7 @@ import { type ProjectRecord, type RadialRecord, type RemoveMemberRecord, + type RetractRequestRecord, type ReviewRecord, type SpaceRecord, type StrongRef, @@ -48,6 +49,10 @@ export interface TargetView { target: IndexedRecord requests: Array> openRequests: Array> + // Requests withdrawn by a `retractRequest` tombstone the space trusts: authored by the request's + // own author or an active admin. They stay in `requests` (rendered with a "retracted" badge, the + // way a removed member still shows) but are excluded from `openRequests` โ€” nothing new can attach. + retracted: Array> awaitingInput: string[] artifacts: Array> artifactChains: ArtifactChain[] @@ -287,6 +292,9 @@ interface ViewInput { merges: Array> messages: Array> claims: Array> + // refKeys (uri#cid) of requests a trusted retract tombstone has withdrawn. Global set, keyed by + // strongref; buildView only sees this target's requests, so cross-target keys never collide. + retractedRefs: Set asOf: string } @@ -350,7 +358,11 @@ function buildView(input: ViewInput): T if (request?.value.assignee === message.did) fulfilled.add(refKey(message.value.re)) } - const openRequests = input.requests.filter((request) => !fulfilled.has(recordRefKey(request))) + const retracted = input.requests.filter((request) => input.retractedRefs.has(recordRefKey(request))) + const openRequests = input.requests.filter( + (request) => + !fulfilled.has(recordRefKey(request)) && !input.retractedRefs.has(recordRefKey(request)), + ) const openRefs = new Set(openRequests.map(recordRefKey)) const winningClaims: Record> = {} const claimsByRequest = new Map>>() @@ -378,6 +390,7 @@ function buildView(input: ViewInput): T target: input.target, requests: input.requests.sort(compareRecord), openRequests: openRequests.sort(compareRecord), + retracted: retracted.sort(compareRecord), awaitingInput: computeAwaitingInput(input.requests, input.messages), artifacts: input.artifacts.sort(compareRecord), artifactChains: buildChains(input.artifacts), @@ -484,6 +497,24 @@ export function materialize(store: RecordStore, options: MaterializeOptions): Ma const requests = [...regularRequests, ...reviewRequests].sort(compareRecord) const requestByRef = new Map(requests.map((request) => [recordRefKey(request), request])) + // A retract tombstone withdraws a request. It is honored only when it targets a request that + // resolves into this space and its author is either that request's author or an active admin + // (design ยง7: authors own their records; admins govern). Trust already dropped retracts from + // non-members; here we additionally gate on the author-or-admin rule and pin the exact uri#cid. + const memberRoleByDid = new Map(trust.members.map((member) => [member.did, member.role])) + const retractedRefs = new Set() + const associatedRetractUris: string[] = [] + for (const retract of trust.records) { + if (retract.collection !== COLLECTIONS.retractRequest) continue + const targetKey = refKey((retract.value as RetractRequestRecord).request) + const request = requestByRef.get(targetKey) + if (!request) continue + associatedRetractUris.push(retract.uri) + if (retract.did === request.did || memberRoleByDid.get(retract.did) === 'admin') { + retractedRefs.add(targetKey) + } + } + const reviews = trust.records.filter( (record): record is IndexedRecord => record.collection === COLLECTIONS.review && @@ -548,6 +579,7 @@ export function materialize(store: RecordStore, options: MaterializeOptions): Ma merges: merges.filter((merge) => targetUriForMerge(merge) === target.uri), messages: messages.filter((message) => targetUriForMessage(message) === target.uri), claims: claims.filter((claim) => targetUriForClaim(claim) === target.uri), + retractedRefs, asOf, }) @@ -575,6 +607,7 @@ export function materialize(store: RecordStore, options: MaterializeOptions): Ma ...merges.map((record) => record.uri), ...claims.map((record) => record.uri), ...messages.map((record) => record.uri), + ...associatedRetractUris, ]) const membershipCollections: string[] = [COLLECTIONS.addMember, COLLECTIONS.removeMember] const unassociated = trust.records diff --git a/packages/core/test/fixtures/invalid-records.json b/packages/core/test/fixtures/invalid-records.json index 27670a5..c03c061 100644 --- a/packages/core/test/fixtures/invalid-records.json +++ b/packages/core/test/fixtures/invalid-records.json @@ -7,6 +7,7 @@ "com.disnetdev.radial.artifactType": {"$type":"com.disnetdev.radial.artifactType","space":{"uri":"at://x","cid":"x"},"name":"x","brief":"x","outputSpec":{"format":"xml","description":"x"},"scope":"team","createdAt":"bad"}, "com.disnetdev.radial.artifactRequest": {"$type":"com.disnetdev.radial.artifactRequest","type":"review","basedOn":[],"createdAt":"bad"}, "com.disnetdev.radial.claim": {"$type":"com.disnetdev.radial.claim","request":{"uri":"at://x","cid":"x"},"expiresAt":"bad","createdAt":"bad"}, + "com.disnetdev.radial.retractRequest": {"$type":"com.disnetdev.radial.retractRequest","request":{"uri":"nope","cid":""},"createdAt":"bad"}, "com.disnetdev.radial.artifact": {"$type":"com.disnetdev.radial.artifact","request":{"uri":"at://x","cid":"x"},"type":"plan","body":"x","links":{},"createdAt":"bad"}, "com.disnetdev.radial.review": {"$type":"com.disnetdev.radial.review","subject":{"uri":"at://x","cid":"x"},"verdict":"maybe","findings":[],"createdAt":"bad"}, "com.disnetdev.radial.checkrun": {"$type":"com.disnetdev.radial.checkrun","artifact":{"uri":"at://x","cid":"x"},"commit":"x","results":[],"createdAt":"bad"}, diff --git a/packages/core/test/fixtures/valid-records.json b/packages/core/test/fixtures/valid-records.json index 2e93b21..ee8c269 100644 --- a/packages/core/test/fixtures/valid-records.json +++ b/packages/core/test/fixtures/valid-records.json @@ -61,6 +61,11 @@ "expiresAt": "2026-01-02T00:00:00Z", "createdAt": "2026-01-01T00:00:06Z" }, + "com.disnetdev.radial.retractRequest": { + "$type": "com.disnetdev.radial.retractRequest", + "request": {"uri": "at://did:plc:human/com.disnetdev.radial.artifactRequest/request", "cid": "cid-request"}, + "createdAt": "2026-01-01T00:00:06Z" + }, "com.disnetdev.radial.artifact": { "$type": "com.disnetdev.radial.artifact", "request": {"uri": "at://did:plc:human/com.disnetdev.radial.artifactRequest/request", "cid": "cid-request"}, diff --git a/packages/core/test/retract.test.mjs b/packages/core/test/retract.test.mjs new file mode 100644 index 0000000..ef1e0a8 --- /dev/null +++ b/packages/core/test/retract.test.mjs @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { materialize } from '../dist/materializer.js' +import { MemoryRecordStore } from '../dist/store.js' +import { goldenScenario } from '../dist/test/scenario.js' +import { COLLECTIONS } from '../dist/generated/records.js' + +const AS_OF = '2026-02-01T00:00:00Z' +const ROOT = 'did:plc:root' +const HUMAN = 'did:plc:human' +const AGENT_C = 'did:plc:agentc' + +/** A retract tombstone authored by `did`, pinning the exact request uri#cid. */ +function retract(did, rkey, request) { + return { + did, + collection: COLLECTIONS.retractRequest, + rkey, + uri: `at://${did}/${COLLECTIONS.retractRequest}/${rkey}`, + cid: `cid-${rkey}`, + rev: '9999999999999', + value: { + $type: COLLECTIONS.retractRequest, + request: { uri: request.uri, cid: request.cid }, + createdAt: '2026-01-01T00:20:00Z', + }, + } +} + +function buildWith(...extra) { + const scenario = goldenScenario() + const store = new MemoryRecordStore() + ;[...scenario.records, ...extra].forEach((record) => store.put(record)) + return { scenario, index: materialize(store, { spaceUri: scenario.spaceUri, asOf: AS_OF }) } +} + +const goalOf = (index, uri) => index.goals.find((view) => view.target.uri === uri) +// The golden scenario's single open request on the primary goal: a plan, unassigned, authored by HUMAN. +const openPlanRequest = (index, goalUri) => + goalOf(index, goalUri).openRequests.find( + (request) => request.value.type === 'plan' && !request.value.assignee, + ) + +describe('materializer request retraction', () => { + it('has exactly one open, unretracted plan request before any tombstone', () => { + const { scenario, index } = buildWith() + const goal = goalOf(index, scenario.goalUri) + assert.ok(openPlanRequest(index, scenario.goalUri), 'baseline open request should exist') + assert.equal(goal.retracted.length, 0) + }) + + it('honors a retract authored by the request author, dropping it from openRequests', () => { + const base = buildWith() + const request = openPlanRequest(base.index, base.scenario.goalUri) + const { scenario, index } = buildWith(retract(HUMAN, 'retract-by-author', request)) + const goal = goalOf(index, scenario.goalUri) + + assert.equal(goal.retracted.some((r) => r.uri === request.uri), true, 'appears in retracted') + assert.equal(goal.openRequests.some((r) => r.uri === request.uri), false, 'gone from open') + assert.equal(goal.requests.some((r) => r.uri === request.uri), true, 'still listed for the badge') + // A now-closed request cannot be claimed: its winning claim drops out too. + assert.equal(Object.keys(goal.winningClaims).includes(request.uri), false) + }) + + it('honors a retract authored by a space admin (root)', () => { + const base = buildWith() + const request = openPlanRequest(base.index, base.scenario.goalUri) + const { scenario, index } = buildWith(retract(ROOT, 'retract-by-admin', request)) + const goal = goalOf(index, scenario.goalUri) + assert.equal(goal.retracted.some((r) => r.uri === request.uri), true) + assert.equal(goal.openRequests.some((r) => r.uri === request.uri), false) + }) + + it('ignores a retract from a member who is neither the author nor an admin', () => { + const base = buildWith() + const request = openPlanRequest(base.index, base.scenario.goalUri) + const { scenario, index } = buildWith(retract(AGENT_C, 'retract-by-stranger', request)) + const goal = goalOf(index, scenario.goalUri) + assert.equal(goal.retracted.length, 0, 'not honored') + assert.equal(goal.openRequests.some((r) => r.uri === request.uri), true, 'still open') + }) +}) diff --git a/packages/lexicons/lexicons/com.disnetdev.radial.retractRequest.json b/packages/lexicons/lexicons/com.disnetdev.radial.retractRequest.json new file mode 100644 index 0000000..0b6a6c0 --- /dev/null +++ b/packages/lexicons/lexicons/com.disnetdev.radial.retractRequest.json @@ -0,0 +1,18 @@ +{ + "lexicon": 1, + "id": "com.disnetdev.radial.retractRequest", + "defs": { + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "required": ["request", "createdAt"], + "properties": { + "request": {"type": "ref", "ref": "com.atproto.repo.strongRef"}, + "createdAt": {"type": "string", "format": "datetime"} + } + } + } + } +} diff --git a/packages/sidecar/src/cli.ts b/packages/sidecar/src/cli.ts index a595535..0287411 100644 --- a/packages/sidecar/src/cli.ts +++ b/packages/sidecar/src/cli.ts @@ -29,6 +29,7 @@ export const help = `radial โ€” human CLI for Radial records radial request create (--goal REF | --project REF) --type TYPE [--assignee HANDLE|DID] [--subject REF] [--based-on REF]... [--brief TEXT] + radial request retract --request REF radial artifact post --request REF (--body TEXT | --body-file PATH) [--prev REF] [--criterion TEXT]... radial message post (--goal REF | --artifact REF) (--body TEXT | --body-file PATH) diff --git a/packages/sidecar/src/commands.ts b/packages/sidecar/src/commands.ts index 77f0f58..d48ee80 100644 --- a/packages/sidecar/src/commands.ts +++ b/packages/sidecar/src/commands.ts @@ -244,6 +244,18 @@ export async function runCli(args: string[], deps: CliDependencies): Promise + const status = retracted + ? 'retracted' + : open + ? 'open' + : 'fulfilled' + return `
${esc(value.type)} request ${copyable(request.uri, request.cid)} - ${open ? 'open' : 'fulfilled'} - ${awaiting ? 'awaiting input' : ''} + ${status} + ${awaiting && !retracted ? 'awaiting input' : ''}
${value.assignee ? `

assignee ${copyable(value.assignee)}

` : '

unassigned

'} ${value.brief ? `
${esc(value.brief)}
` : ''} @@ -315,6 +321,18 @@ function requestCard( ` : '' } + ${ + retracted + ? '' + : `
Delete this request +

Writes a retract tombstone (nothing is hard-deleted). Only honored if the acting identity authored the request or is a space admin.

+
+ + + ${profileSelect(profiles)} + +
` + }
` } @@ -467,6 +485,7 @@ export function goalPage( const goalUri = goal.target.uri const spaceUri = index.space.uri const awaiting = new Set(goal.awaitingInput) + const retracted = new Set(goal.retracted.map((request) => request.uri)) const requests = goal.requests.length ? goal.requests @@ -475,6 +494,7 @@ export function goalPage( request, goal.openRequests.some((open) => open.uri === request.uri), awaiting.has(request.uri), + retracted.has(request.uri), profiles, goalUri, ), diff --git a/packages/ui/test/build-action.test.mjs b/packages/ui/test/build-action.test.mjs index 3333e5b..3d9056b 100644 --- a/packages/ui/test/build-action.test.mjs +++ b/packages/ui/test/build-action.test.mjs @@ -70,6 +70,13 @@ describe('buildAction request shapes', () => { it('rejects an unknown action path', () => { assert.throws(() => buildAction('/nope', new URLSearchParams()), /Unknown action/) }) + + it('maps a "Delete request" form to a retract carrying --request and redirects to the goal', () => { + const request = `${BASIS}` + const built = buildAction('/request/retract', new URLSearchParams({ goal: GOAL, request })) + assert.deepEqual(built.args, ['request', 'retract', '--request', request]) + assert.equal(built.redirect({ primary: { uri: 'ignored' } }), `/goal?uri=${encodeURIComponent(GOAL)}`) + }) }) const SPACE = 'at://did:plc:alice/com.disnetdev.radial.space/space' @@ -138,4 +145,14 @@ describe('buildAction shapes drive runCli to the intended record', () => { assert.ok(value.subject, 'subject strongref should be set') assert.equal(value.subject.uri, BASIS.split('#')[0]) }) + + it('a delete form lands a retractRequest tombstone pinning the request', async () => { + const { deps, written } = harness() + const form = new URLSearchParams({ goal: GOAL, request: BASIS }) + await runCli(buildAction('/request/retract', form).args, deps) + const record = written.at(-1) + assert.equal(record.collection, COLLECTIONS.retractRequest) + assert.equal(record.value.request.uri, BASIS.split('#')[0]) + assert.equal(record.value.request.cid, BASIS.split('#')[1]) + }) })