Open Collection Removal Logic Implementation#
Date: January 22, 2026
Overview#
Implemented card removal logic for OPEN collections in the ATProto-based application, including the creation of a new collectionLinkRemoval lexicon record type to handle cases where collection owners need to remove cards added by other users.
Problem Statement#
In ATProto, users can only modify records in their own repositories. When a collection owner wants to remove a card that was added by another user from an OPEN collection, they cannot delete the collectionLink record (which exists in the other user's repository). A solution was needed to mark these cards as removed without requiring direct deletion of another user's records.
Solution: CollectionLinkRemoval Record#
Implemented Option 2 from the design phase: a separate collectionLinkRemoval record type that collection owners publish in their own repository to indicate a card has been removed.
Lexicon Schema#
Created src/modules/atproto/infrastructure/lexicons/collectionLinkRemoval.json:
{
"lexicon": 1,
"id": "network.cosmik.collectionLinkRemoval",
"description": "A record indicating that a card was removed from a collection by the collection owner.",
"defs": {
"main": {
"type": "record",
"description": "A record representing the removal of a collection link by a collection owner when they cannot delete the original link (which exists in another user's repository). The creator of this record (determined from the AT-URI) is the user who performed the removal.",
"key": "tid",
"record": {
"type": "object",
"required": ["collection", "removedLink", "removedAt"],
"properties": {
"collection": {
"type": "ref",
"description": "Strong reference to the collection record.",
"ref": "com.atproto.repo.strongRef"
},
"removedLink": {
"type": "ref",
"description": "Strong reference to the collectionLink record that is being removed.",
"ref": "com.atproto.repo.strongRef"
},
"removedAt": {
"type": "string",
"format": "datetime",
"description": "Timestamp when the link was removed from the collection."
}
}
}
}
}
}
Design Notes:
- No
removedByfield - user identity is derived from the AT-URI of the removal record - No
reasonfield - kept the schema minimal and focused - Uses StrongRef to ensure content-addressable references (URI + CID)
Business Rules for OPEN Collections#
Removal Permissions#
For OPEN Collections:
- ✅ Collection author can remove any card (including cards added by others)
- ✅ Users can only remove cards they themselves added
- ❌ Non-authors cannot remove cards added by others
ATProto Publishing Logic#
When removing a card from an OPEN collection:
-
User removing their own card:
- Unpublishes the
collectionLinkrecord (deletes from their repository) - No removal record is created
- Unpublishes the
-
Collection author removing someone else's card:
- Publishes a
collectionLinkRemovalrecord in their own repository - The original
collectionLinkremains in the other user's repository (cannot be deleted)
- Publishes a
Implementation Details#
1. Domain Layer#
File: src/modules/cards/domain/Collection.ts
Updated the removeCard() method (lines 260-309) to enforce permissions based on collection type:
public removeCard(
cardId: CardId,
userId: CuratorId,
): Result<void, CollectionAccessError> {
// Find the card link to check who added it
const cardLink = this.props.cardLinks.find((link) =>
link.cardId.equals(cardId),
);
// If card is not in collection, removal is a no-op (succeeds idempotently)
if (!cardLink) {
return ok(undefined);
}
// Check removal permissions based on collection type and user role
const isAuthor = this.props.authorId.equals(userId);
const isUserRemovingOwnCard = cardLink.addedBy.equals(userId);
if (this.isOpen) {
// For OPEN collections:
// - Author can remove any card
// - Users can only remove cards they added themselves
if (!isAuthor && !isUserRemovingOwnCard) {
return err(
new CollectionAccessError(
'User does not have permission to remove cards from this collection',
),
);
}
} else {
// For CLOSED collections:
// Use the standard canAddCard check (author + collaborators)
// Note: CLOSED collection removal scenarios with collaborators are deferred
if (!this.canAddCard(userId)) {
return err(
new CollectionAccessError(
'User does not have permission to remove cards from this collection',
),
);
}
}
this.props.cardLinks = this.props.cardLinks.filter(
(link) => !link.cardId.equals(cardId),
);
this.props.cardCount = this.props.cardLinks.length;
this.props.updatedAt = new Date();
return ok(undefined);
}
2. Service Layer#
File: src/modules/cards/domain/services/CardCollectionService.ts
The removeCardFromCollection method (lines 213-279) handles the publishing logic:
// Check permissions FIRST before attempting any publishing operations
const canRemoveResult = collection.removeCard(card.cardId, curatorId);
if (canRemoveResult.isErr()) {
return err(new CardCollectionValidationError(...));
}
// Re-add the card since we only wanted to check permissions
collection.addCard(card.cardId, cardLink.addedBy, cardLink.viaCardId);
// Handle unpublishing/removal based on options
if (!options?.skipPublishing && cardLink.publishedRecordId) {
const isUserRemovingOwnCard = cardLink.addedBy.equals(curatorId);
const isCollectionAuthor = collection.authorId.equals(curatorId);
if (isUserRemovingOwnCard) {
// Unpublish the CollectionLink (delete from their repo)
await collectionPublisher.unpublishCardAddedToCollection(...);
} else if (isCollectionAuthor) {
// Publish a CollectionLinkRemoval record (only author can do this)
await collectionPublisher.publishCollectionLinkRemoval(...);
} else {
// Should never happen due to permission check above
return err(new CardCollectionValidationError(
'User does not have permission to remove this card from the collection'
));
}
}
Key Design Decision: Permission check happens BEFORE any ATProto publishing operations to prevent unauthorized publishes.
3. Infrastructure Layer#
File: src/modules/atproto/infrastructure/publishers/ATProtoCollectionPublisher.ts
Added publishCollectionLinkRemoval method (lines 280-350):
async publishCollectionLinkRemoval(
card: Card,
collection: Collection,
curatorId: CuratorId,
removedLinkRef: PublishedRecordId,
): Promise<Result<PublishedRecordId, UseCaseError>> {
// ... validation and auth ...
const removalRecordDTO = CollectionLinkRemovalMapper.toCreateRecordDTO(
collection.publishedRecordId.getValue(),
removedLinkRef.getValue(),
);
const createResult = await agent.com.atproto.repo.createRecord({
repo: curatorDid.value,
collection: this.collectionLinkRemovalCollection,
record: removalRecordDTO,
});
return ok(PublishedRecordId.create({...}));
}
4. Mapper#
File: src/modules/atproto/infrastructure/mappers/CollectionLinkRemovalMapper.ts (CREATED)
Maps domain data to removal record DTOs:
static toCreateRecordDTO(
collectionPublishedRecordId: PublishedRecordIdProps,
removedLinkPublishedRecordId: PublishedRecordIdProps,
): CollectionLinkRemovalRecordDTO {
const record: CollectionLinkRemovalRecordDTO = {
$type: this.collectionLinkRemovalType as any,
collection: {
uri: collectionPublishedRecordId.uri,
cid: collectionPublishedRecordId.cid,
},
removedLink: {
uri: removedLinkPublishedRecordId.uri,
cid: removedLinkPublishedRecordId.cid,
},
removedAt: new Date().toISOString(),
};
return record;
}
Files Modified#
Created#
src/modules/atproto/infrastructure/lexicons/collectionLinkRemoval.json- Lexicon schemasrc/modules/atproto/infrastructure/mappers/CollectionLinkRemovalMapper.ts- Mapper for removal records
Modified#
src/modules/cards/domain/Collection.ts- UpdatedremoveCard()method with proper permissionssrc/modules/cards/domain/services/CardCollectionService.ts- Updated removal logic with publishing decisionssrc/modules/cards/application/ports/ICollectionPublisher.ts- AddedpublishCollectionLinkRemovalmethod signaturesrc/modules/atproto/infrastructure/publishers/ATProtoCollectionPublisher.ts- ImplementedpublishCollectionLinkRemovalsrc/modules/cards/tests/utils/FakeCollectionPublisher.ts- Added test implementation for removal trackingsrc/shared/infrastructure/config/EnvironmentConfigService.ts- AddedcollectionLinkRemovalconfigsrc/shared/infrastructure/http/factories/ServiceFactory.ts- Updated ATProtoCollectionPublisher instantiationsrc/modules/cards/tests/application/RemoveCardFromCollectionUseCase.test.ts- Updated/added tests
Generated#
- TypeScript types regenerated via
npm run lexgen
Test Coverage#
Test Results#
- ✅ 20 tests passed in RemoveCardFromCollectionUseCase
- ✅ 2 tests skipped (CLOSED collections with collaborators - out of scope)
- ✅ 148 total collection-related tests passed
Test Scenarios Added#
- Collection owner removes contributor's card → publishes removal record
- Contributor removes their own card → unpublishes link
- Owner removes their own card → unpublishes link
- Non-author tries to remove another user's card → fails with permission error
Skipped Tests (Out of Scope)#
The following tests were skipped as CLOSED collection scenarios with collaborators are deferred:
should allow collaborator to remove cards from closed collectionshould handle mixed collection permissions when removing cards
Enforcement Layers#
The removal rules are enforced at two layers for defense in depth:
- Domain Layer (
Collection.removeCard()) - First line of defense, encapsulates business rules - Service Layer (
CardCollectionService.removeCardFromCollection()) - Handles ATProto publishing logic
This ensures business rules are properly encapsulated in the domain model where they belong.
Future Work#
CLOSED Collections with Collaborators#
Deferred for future implementation:
- How collaborators can remove cards from CLOSED collections
- Whether collaborators can remove any card or only their own
- Permission rules for multi-user CLOSED collection scenarios
Potential Enhancements#
- Add a
reasonfield to collectionLinkRemoval for moderation use cases - Implement bulk removal operations
- Add notifications when cards are removed from collections
- Query support for fetching removal records from the firehose
Configuration#
Added environment configuration for the collectionLinkRemoval collection:
collections: {
card: 'network.cosmik.card',
collection: 'network.cosmik.collection',
collectionLink: 'network.cosmik.collectionLink',
collectionLinkRemoval: 'network.cosmik.collectionLinkRemoval', // NEW
}
References#
- ATProto Documentation: https://atproto.com/
- Lexicon Specification: https://atproto.com/specs/lexicon
- StrongRef Definition:
com.atproto.repo.strongRef
Notes#
- The implementation focuses exclusively on OPEN collections as specified by the user
- Permission checks occur before any ATProto publishing to prevent unauthorized operations
- The design follows the vertical slice architecture pattern used throughout the codebase
- Error handling uses the Result/Either pattern for type-safe error propagation