MARKED_FOR_DELETION_AT = DSL.field("marked_for_deletion_at", SQLDataType.TIMESTAMPWITHTIMEZONE.nullable(true));
+
+ private ContentReferenceTable() {}
+}
diff --git a/contentgrid-appserver-content-lifecycle/src/main/java/com/contentgrid/appserver/content/lifecycle/ContentReferenceTracker.java b/contentgrid-appserver-content-lifecycle/src/main/java/com/contentgrid/appserver/content/lifecycle/ContentReferenceTracker.java
new file mode 100644
index 000000000..25314b85f
--- /dev/null
+++ b/contentgrid-appserver-content-lifecycle/src/main/java/com/contentgrid/appserver/content/lifecycle/ContentReferenceTracker.java
@@ -0,0 +1,11 @@
+package com.contentgrid.appserver.content.lifecycle;
+
+import com.contentgrid.appserver.contentstore.api.ContentReference;
+
+public interface ContentReferenceTracker {
+
+ void incrementReference(ContentReference ref);
+
+ void decrementReference(ContentReference ref);
+
+}
diff --git a/contentgrid-appserver-content-lifecycle/src/main/java/com/contentgrid/appserver/content/lifecycle/ContentReferenceVerificationQuery.java b/contentgrid-appserver-content-lifecycle/src/main/java/com/contentgrid/appserver/content/lifecycle/ContentReferenceVerificationQuery.java
new file mode 100644
index 000000000..0d51bb4cc
--- /dev/null
+++ b/contentgrid-appserver-content-lifecycle/src/main/java/com/contentgrid/appserver/content/lifecycle/ContentReferenceVerificationQuery.java
@@ -0,0 +1,16 @@
+package com.contentgrid.appserver.content.lifecycle;
+
+import com.contentgrid.appserver.application.model.Application;
+import com.contentgrid.appserver.contentstore.api.ContentReference;
+
+/**
+ * Verifies whether a content object is still referenced by any entity in the application data model.
+ * Used as a safety check before physically deleting content from the content store.
+ */
+public interface ContentReferenceVerificationQuery {
+
+ /**
+ * Returns true if the given content reference is still referenced by at least one entity in any entity table.
+ */
+ boolean isReferenced(Application application, ContentReference ref);
+}
diff --git a/contentgrid-appserver-content-lifecycle/src/main/java/com/contentgrid/appserver/content/lifecycle/DeferredContentReferenceTracker.java b/contentgrid-appserver-content-lifecycle/src/main/java/com/contentgrid/appserver/content/lifecycle/DeferredContentReferenceTracker.java
new file mode 100644
index 000000000..cb36400a9
--- /dev/null
+++ b/contentgrid-appserver-content-lifecycle/src/main/java/com/contentgrid/appserver/content/lifecycle/DeferredContentReferenceTracker.java
@@ -0,0 +1,37 @@
+package com.contentgrid.appserver.content.lifecycle;
+
+import com.contentgrid.appserver.contentstore.api.ContentReference;
+import lombok.RequiredArgsConstructor;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+
+/**
+ * Wraps a {@link ContentReferenceTracker} to defer {@link #decrementReference} until after the current transaction
+ * commits. If no transaction is active, the decrement is called directly.
+ *
+ * This prevents premature deletion markers when the entity transaction rolls back.
+ */
+@RequiredArgsConstructor
+public class DeferredContentReferenceTracker implements ContentReferenceTracker {
+
+ private final ContentReferenceTracker delegate;
+
+ @Override
+ public void incrementReference(ContentReference ref) {
+ delegate.incrementReference(ref);
+ }
+
+ @Override
+ public void decrementReference(ContentReference ref) {
+ if (TransactionSynchronizationManager.isSynchronizationActive()) {
+ TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+ @Override
+ public void afterCommit() {
+ delegate.decrementReference(ref);
+ }
+ });
+ } else {
+ delegate.decrementReference(ref);
+ }
+ }
+}
diff --git a/contentgrid-appserver-content-lifecycle/src/main/java/com/contentgrid/appserver/content/lifecycle/JooqContentReferenceTracker.java b/contentgrid-appserver-content-lifecycle/src/main/java/com/contentgrid/appserver/content/lifecycle/JooqContentReferenceTracker.java
new file mode 100644
index 000000000..a05647b07
--- /dev/null
+++ b/contentgrid-appserver-content-lifecycle/src/main/java/com/contentgrid/appserver/content/lifecycle/JooqContentReferenceTracker.java
@@ -0,0 +1,39 @@
+package com.contentgrid.appserver.content.lifecycle;
+
+import com.contentgrid.appserver.contentstore.api.ContentReference;
+import java.time.OffsetDateTime;
+import lombok.RequiredArgsConstructor;
+import org.jooq.DSLContext;
+import org.jooq.impl.DSL;
+
+@RequiredArgsConstructor
+public class JooqContentReferenceTracker implements ContentReferenceTracker {
+
+ private final DSLContext dslContext;
+
+ @Override
+ public void incrementReference(ContentReference ref) {
+ dslContext.insertInto(ContentReferenceTable.TABLE)
+ .set(ContentReferenceTable.CONTENT_ID, ref.getValue())
+ .set(ContentReferenceTable.REFERENCE_COUNT, 1)
+ .set(ContentReferenceTable.FIRST_REFERENCED_AT, OffsetDateTime.now())
+ .onConflict(ContentReferenceTable.CONTENT_ID)
+ .doUpdate()
+ .set(ContentReferenceTable.REFERENCE_COUNT,
+ DSL.field(DSL.name("_content_references", "reference_count"), Integer.class).add(1))
+ .set(ContentReferenceTable.MARKED_FOR_DELETION_AT, (OffsetDateTime) null)
+ .execute();
+ }
+
+ @Override
+ public void decrementReference(ContentReference ref) {
+ dslContext.update(ContentReferenceTable.TABLE)
+ .set(ContentReferenceTable.REFERENCE_COUNT, ContentReferenceTable.REFERENCE_COUNT.subtract(1))
+ .set(ContentReferenceTable.LAST_DEREFERENCED_AT, OffsetDateTime.now())
+ .set(ContentReferenceTable.MARKED_FOR_DELETION_AT,
+ DSL.when(ContentReferenceTable.REFERENCE_COUNT.subtract(1).eq(0), OffsetDateTime.now())
+ .otherwise(ContentReferenceTable.MARKED_FOR_DELETION_AT))
+ .where(ContentReferenceTable.CONTENT_ID.eq(ref.getValue()))
+ .execute();
+ }
+}
diff --git a/contentgrid-appserver-content-lifecycle/src/main/java/com/contentgrid/appserver/content/lifecycle/JooqContentReferenceVerificationQuery.java b/contentgrid-appserver-content-lifecycle/src/main/java/com/contentgrid/appserver/content/lifecycle/JooqContentReferenceVerificationQuery.java
new file mode 100644
index 000000000..dbed8c0c7
--- /dev/null
+++ b/contentgrid-appserver-content-lifecycle/src/main/java/com/contentgrid/appserver/content/lifecycle/JooqContentReferenceVerificationQuery.java
@@ -0,0 +1,29 @@
+package com.contentgrid.appserver.content.lifecycle;
+
+import com.contentgrid.appserver.application.model.Application;
+import com.contentgrid.appserver.contentstore.api.ContentReference;
+import lombok.RequiredArgsConstructor;
+import org.jooq.Allow;
+import org.jooq.DSLContext;
+import org.jooq.impl.DSL;
+
+@RequiredArgsConstructor
+public class JooqContentReferenceVerificationQuery implements ContentReferenceVerificationQuery {
+
+ private final DSLContext dslContext;
+
+ @Override
+ @Allow.PlainSQL
+ public boolean isReferenced(Application application, ContentReference ref) {
+ return application.getEntities().stream()
+ .anyMatch(entity -> entity.getContentAttributes().stream()
+ .anyMatch(contentAttribute -> dslContext.fetchExists(
+ dslContext.selectOne()
+ .from(DSL.table(entity.getTable().getValue()))
+ .where(DSL.field(
+ contentAttribute.getId().getColumn().getValue(),
+ String.class
+ ).eq(ref.getValue()))
+ )));
+ }
+}
diff --git a/contentgrid-appserver-content-lifecycle/src/test/java/com/contentgrid/appserver/content/lifecycle/ContentDeletionJobTest.java b/contentgrid-appserver-content-lifecycle/src/test/java/com/contentgrid/appserver/content/lifecycle/ContentDeletionJobTest.java
new file mode 100644
index 000000000..d96ec8c67
--- /dev/null
+++ b/contentgrid-appserver-content-lifecycle/src/test/java/com/contentgrid/appserver/content/lifecycle/ContentDeletionJobTest.java
@@ -0,0 +1,125 @@
+package com.contentgrid.appserver.content.lifecycle;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+import com.contentgrid.appserver.application.model.Application;
+import com.contentgrid.appserver.contentstore.api.ContentReference;
+import com.contentgrid.appserver.contentstore.api.ContentStore;
+import com.contentgrid.appserver.contentstore.api.UnwritableContentException;
+import io.micrometer.core.instrument.Counter;
+import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
+import java.util.List;
+import org.jooq.Condition;
+import org.jooq.DSLContext;
+import org.jooq.Field;
+import org.jooq.SelectField;
+import org.jooq.Table;
+import org.mockito.Answers;
+import org.mockito.ArgumentMatchers;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.springframework.boot.DefaultApplicationArguments;
+
+@ExtendWith(MockitoExtension.class)
+class ContentDeletionJobTest {
+
+ @Mock(answer = Answers.RETURNS_DEEP_STUBS)
+ DSLContext dslContext;
+
+ @Mock
+ ContentStore contentStore;
+
+ @Mock
+ ContentReferenceVerificationQuery verificationQuery;
+
+ @Mock
+ Application application;
+
+ SimpleMeterRegistry meterRegistry;
+ ContentLifecycleProperties properties;
+ ContentDeletionJob job;
+
+ @BeforeEach
+ void setup() {
+ meterRegistry = new SimpleMeterRegistry();
+ properties = new ContentLifecycleProperties();
+ job = new ContentDeletionJob(dslContext, contentStore, verificationQuery, application, meterRegistry, properties);
+ }
+
+ private void givenCandidates(List contentIds) {
+ given(dslContext.select(ArgumentMatchers.>any())
+ .from(ArgumentMatchers.>any())
+ .where(ArgumentMatchers.any())
+ .limit(anyInt())
+ .fetch(ArgumentMatchers.>any()))
+ .willReturn(contentIds);
+ }
+
+ private double getCounter(String name) {
+ Counter counter = meterRegistry.find(name).counter();
+ return counter != null ? counter.count() : 0.0;
+ }
+
+ @Test
+ void candidate_unreferenced_isDeleted() throws Exception {
+ var contentId = "content-abc.bin";
+ givenCandidates(List.of(contentId));
+ given(verificationQuery.isReferenced(any(), any())).willReturn(false);
+
+ job.run(new DefaultApplicationArguments());
+
+ verify(contentStore).remove(ContentReference.of(contentId));
+ assertThat(getCounter("content.deletion.success")).isEqualTo(1.0);
+ assertThat(getCounter("content.deletion.failure")).isEqualTo(0.0);
+ assertThat(getCounter("content.deletion.drift")).isEqualTo(0.0);
+ }
+
+ @Test
+ void noCandidates_withinGracePeriod_nothingDeleted() throws Exception {
+ givenCandidates(List.of());
+
+ job.run(new DefaultApplicationArguments());
+
+ verify(contentStore, never()).remove(any());
+ assertThat(getCounter("content.deletion.success")).isEqualTo(0.0);
+ }
+
+ @Test
+ void candidate_drift_markerCleared_noDelete() throws Exception {
+ var contentId = "content-drift.bin";
+ givenCandidates(List.of(contentId));
+ given(verificationQuery.isReferenced(any(), any())).willReturn(true);
+
+ job.run(new DefaultApplicationArguments());
+
+ verify(contentStore, never()).remove(any());
+ assertThat(getCounter("content.deletion.drift")).isEqualTo(1.0);
+ assertThat(getCounter("content.deletion.success")).isEqualTo(0.0);
+ }
+
+ @Test
+ void candidate_contentStoreRemoveFails_continuesAndRecordsFailure() throws Exception {
+ var contentId1 = "content-fail.bin";
+ var contentId2 = "content-ok.bin";
+ givenCandidates(List.of(contentId1, contentId2));
+ given(verificationQuery.isReferenced(any(), any())).willReturn(false);
+ doThrow(new UnwritableContentException(ContentReference.of(contentId1)))
+ .when(contentStore).remove(ContentReference.of(contentId1));
+
+ job.run(new DefaultApplicationArguments());
+
+ verify(contentStore).remove(ContentReference.of(contentId1));
+ verify(contentStore).remove(ContentReference.of(contentId2));
+ assertThat(getCounter("content.deletion.failure")).isEqualTo(1.0);
+ assertThat(getCounter("content.deletion.success")).isEqualTo(1.0);
+ }
+}
diff --git a/contentgrid-appserver-content-lifecycle/src/test/java/com/contentgrid/appserver/content/lifecycle/DeferredContentReferenceTrackerTest.java b/contentgrid-appserver-content-lifecycle/src/test/java/com/contentgrid/appserver/content/lifecycle/DeferredContentReferenceTrackerTest.java
new file mode 100644
index 000000000..3ea094432
--- /dev/null
+++ b/contentgrid-appserver-content-lifecycle/src/test/java/com/contentgrid/appserver/content/lifecycle/DeferredContentReferenceTrackerTest.java
@@ -0,0 +1,82 @@
+package com.contentgrid.appserver.content.lifecycle;
+
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+import com.contentgrid.appserver.contentstore.api.ContentReference;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+
+@ExtendWith(MockitoExtension.class)
+class DeferredContentReferenceTrackerTest {
+
+ @Mock
+ ContentReferenceTracker delegate;
+
+ @Test
+ void incrementReference_alwaysDelegatesDirectly() {
+ var tracker = new DeferredContentReferenceTracker(delegate);
+ var ref = ContentReference.of("test-content");
+
+ TransactionSynchronizationManager.initSynchronization();
+ try {
+ tracker.incrementReference(ref);
+ } finally {
+ TransactionSynchronizationManager.clearSynchronization();
+ }
+
+ verify(delegate).incrementReference(ref);
+ }
+
+ @Test
+ void decrementReference_outsideTransaction_callsDirectly() {
+ var tracker = new DeferredContentReferenceTracker(delegate);
+ var ref = ContentReference.of("test-content");
+
+ tracker.decrementReference(ref);
+
+ verify(delegate).decrementReference(ref);
+ }
+
+ @Test
+ void decrementReference_insideTransaction_deferredToAfterCommit() {
+ var tracker = new DeferredContentReferenceTracker(delegate);
+ var ref = ContentReference.of("test-content");
+
+ TransactionSynchronizationManager.initSynchronization();
+ try {
+ tracker.decrementReference(ref);
+
+ // Should NOT be called yet — still inside transaction
+ verify(delegate, never()).decrementReference(ref);
+
+ // Simulate commit: invoke afterCommit on all synchronizations
+ TransactionSynchronizationManager.getSynchronizations()
+ .forEach(s -> s.afterCommit());
+ } finally {
+ TransactionSynchronizationManager.clearSynchronization();
+ }
+
+ // Should be called now
+ verify(delegate).decrementReference(ref);
+ }
+
+ @Test
+ void decrementReference_insideTransaction_notCalledOnRollback() {
+ var tracker = new DeferredContentReferenceTracker(delegate);
+ var ref = ContentReference.of("test-content");
+
+ TransactionSynchronizationManager.initSynchronization();
+ try {
+ tracker.decrementReference(ref);
+ } finally {
+ // Simulate rollback: just clear synchronizations without invoking afterCommit
+ TransactionSynchronizationManager.clearSynchronization();
+ }
+
+ verify(delegate, never()).decrementReference(ref);
+ }
+}
diff --git a/contentgrid-appserver-content-lifecycle/src/test/java/com/contentgrid/appserver/content/lifecycle/JooqContentReferenceTrackerTest.java b/contentgrid-appserver-content-lifecycle/src/test/java/com/contentgrid/appserver/content/lifecycle/JooqContentReferenceTrackerTest.java
new file mode 100644
index 000000000..bff67e1fd
--- /dev/null
+++ b/contentgrid-appserver-content-lifecycle/src/test/java/com/contentgrid/appserver/content/lifecycle/JooqContentReferenceTrackerTest.java
@@ -0,0 +1,51 @@
+package com.contentgrid.appserver.content.lifecycle;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.BDDMockito.then;
+
+import com.contentgrid.appserver.contentstore.api.ContentReference;
+import java.time.OffsetDateTime;
+import org.jooq.DSLContext;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Answers;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+class JooqContentReferenceTrackerTest {
+
+ @Mock(answer = Answers.RETURNS_DEEP_STUBS)
+ DSLContext dslContext;
+
+ JooqContentReferenceTracker tracker;
+
+ @BeforeEach
+ void setup() {
+ tracker = new JooqContentReferenceTracker(dslContext);
+ }
+
+ @Test
+ void incrementReference_insertsOrIncrementsRow() {
+ var ref = ContentReference.of("content-abc");
+
+ tracker.incrementReference(ref);
+
+ // Verify the upsert chain was called with the content_id
+ then(dslContext).should().insertInto(ContentReferenceTable.TABLE);
+ }
+
+ @Test
+ void decrementReference_updatesRow() {
+ var ref = ContentReference.of("content-abc");
+
+ tracker.decrementReference(ref);
+
+ // Verify the update chain was invoked
+ then(dslContext).should().update(ContentReferenceTable.TABLE);
+ }
+}
diff --git a/contentgrid-appserver-domain/build.gradle b/contentgrid-appserver-domain/build.gradle
index 47ca17e69..9856f478a 100644
--- a/contentgrid-appserver-domain/build.gradle
+++ b/contentgrid-appserver-domain/build.gradle
@@ -11,6 +11,7 @@ dependencies {
api 'com.fasterxml.jackson.core:jackson-core'
implementation 'org.slf4j:slf4j-api'
+ implementation project(':contentgrid-appserver-content-lifecycle')
implementation project(':contentgrid-appserver-contentstore-api')
implementation 'org.springframework:spring-core'
implementation 'com.contentgrid.hateoas:contentgrid-pagination-offset:0.0.4'
diff --git a/contentgrid-appserver-domain/src/main/java/com/contentgrid/appserver/domain/DatamodelApiImpl.java b/contentgrid-appserver-domain/src/main/java/com/contentgrid/appserver/domain/DatamodelApiImpl.java
index 21553b8a2..0c7077f7d 100644
--- a/contentgrid-appserver-domain/src/main/java/com/contentgrid/appserver/domain/DatamodelApiImpl.java
+++ b/contentgrid-appserver-domain/src/main/java/com/contentgrid/appserver/domain/DatamodelApiImpl.java
@@ -1,8 +1,12 @@
package com.contentgrid.appserver.domain;
import com.contentgrid.appserver.application.model.Application;
+import com.contentgrid.appserver.content.lifecycle.ContentReferenceTracker;
import com.contentgrid.appserver.application.model.Entity;
+import com.contentgrid.appserver.contentstore.api.ContentReference;
+import com.contentgrid.appserver.application.model.attributes.ContentAttribute;
import com.contentgrid.appserver.application.model.attributes.Attribute;
+import com.contentgrid.appserver.application.model.values.AttributeName;
import com.contentgrid.appserver.application.model.values.EntityName;
import com.contentgrid.appserver.contentstore.api.ContentStore;
import com.contentgrid.appserver.domain.authorization.AuthorizationContext;
@@ -49,7 +53,9 @@
import com.contentgrid.appserver.query.engine.api.UnlinkEventConsumer;
import com.contentgrid.appserver.query.engine.api.UpdateEventConsumer;
import com.contentgrid.appserver.query.engine.api.data.AttributeData;
+import com.contentgrid.appserver.query.engine.api.data.CompositeAttributeData;
import com.contentgrid.appserver.query.engine.api.data.EntityCreateData;
+import com.contentgrid.appserver.query.engine.api.data.SimpleAttributeData;
import com.contentgrid.appserver.query.engine.api.data.EntityData;
import com.contentgrid.appserver.query.engine.api.data.OffsetData;
import com.contentgrid.appserver.query.engine.api.data.SortData;
@@ -68,6 +74,7 @@
import java.util.Optional;
import java.util.Set;
import java.util.function.Supplier;
+import java.util.stream.Stream;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -81,6 +88,7 @@ public class DatamodelApiImpl implements DatamodelApi {
private final DomainEventDispatcher domainEventDispatcher;
private final CursorCodec cursorCodec;
private final Clock clock;
+ private final ContentReferenceTracker contentReferenceTracker;
private RequestInputDataMapper createInputDataMapper(
@NonNull Application application,
@@ -109,7 +117,7 @@ private RequestInputDataMapper createInputDataMapper(
))
.andThen(new OptionalFlatMapAdaptingMapper<>(
AttributeAndRelationMapper.from(
- new ContentUploadAttributeMapper(contentStore),
+ new ContentUploadAttributeMapper(contentStore, contentReferenceTracker),
(rel, value) -> Optional.of(value)
)
))
@@ -273,6 +281,7 @@ public InternalEntityInstance update(@NonNull Application application,
@NonNull AuthorizationContext authorizationContext
)
throws QueryEngineException, InvalidPropertyDataException {
+ var contentValidator = new ContentAttributeModificationValidator(existingEntity);
var inputMapper = createInputDataMapper(
application,
existingEntity.getIdentity().getEntityName(),
@@ -281,7 +290,7 @@ public InternalEntityInstance update(@NonNull Application application,
// Validate that content attribute is not partially set
.andThen(new OptionalFlatMapAdaptingMapper<>(
AttributeAndRelationMapper.from(
- new AttributeValidationDataMapper(new ContentAttributeModificationValidator(existingEntity)),
+ new AttributeValidationDataMapper(contentValidator),
(rel, d) -> Optional.of(d)
)
)),
@@ -303,6 +312,12 @@ public InternalEntityInstance update(@NonNull Application application,
UpdateEventConsumer onUpdate = new EventConsumerImpl(outputMapper);
var updateData = queryEngine.update(application, entityData, authorizationContext.predicate(), onUpdate);
+ if (existingEntity instanceof InternalEntityInstance internalExisting) {
+ extractClearedContentReferences(application, existingEntity.getIdentity().getEntityName(),
+ internalExisting, contentValidator.getDereferencedAttributeNames())
+ .forEach(contentReferenceTracker::decrementReference);
+ }
+
return outputMapper.mapAttributes(updateData.getUpdated());
}
@@ -312,6 +327,7 @@ public InternalEntityInstance updatePartial(@NonNull Application application,
@NonNull RequestInputData data,
@NonNull AuthorizationContext authorizationContext
) throws QueryEngineException, InvalidPropertyDataException {
+ var contentValidator = new ContentAttributeModificationValidator(existingEntity);
var inputMapper = createInputDataMapper(
application,
existingEntity.getIdentity().getEntityName(),
@@ -320,7 +336,7 @@ public InternalEntityInstance updatePartial(@NonNull Application application,
// Validate that content attribute is not partially set
.andThen(new OptionalFlatMapAdaptingMapper<>(
AttributeAndRelationMapper.from(
- new AttributeValidationDataMapper(new ContentAttributeModificationValidator(existingEntity)),
+ new AttributeValidationDataMapper(contentValidator),
(rel, d) -> Optional.of(d)
)
)),
@@ -342,6 +358,12 @@ public InternalEntityInstance updatePartial(@NonNull Application application,
UpdateEventConsumer onUpdate = new EventConsumerImpl(outputMapper);
var updateData = queryEngine.update(application, entityData, authorizationContext.predicate(), onUpdate);
+ if (existingEntity instanceof InternalEntityInstance internalExisting) {
+ extractClearedContentReferences(application, existingEntity.getIdentity().getEntityName(),
+ internalExisting, contentValidator.getDereferencedAttributeNames())
+ .forEach(contentReferenceTracker::decrementReference);
+ }
+
return outputMapper.mapAttributes(updateData.getUpdated());
}
@@ -354,7 +376,38 @@ public InternalEntityInstance deleteEntity(@NonNull Application application, @No
var deleted = queryEngine.delete(application, entityRequest, authorizationContext.predicate(), onDelete)
.orElseThrow(() -> new EntityIdNotFoundException(entityRequest));
- return outputMapper.mapAttributes(deleted);
+ var deletedInstance = outputMapper.mapAttributes(deleted);
+ extractContentReferences(application, entityRequest.getEntityName(), deletedInstance)
+ .forEach(contentReferenceTracker::decrementReference);
+
+ return deletedInstance;
+ }
+
+ private List extractContentReferences(Application application, EntityName entityName, InternalEntityInstance entityInstance) {
+ return application.getRequiredEntityByName(entityName).getContentAttributes().stream()
+ .flatMap(contentAttribute -> extractContentReference(entityInstance, contentAttribute).stream())
+ .toList();
+ }
+
+ private List extractClearedContentReferences(Application application, EntityName entityName,
+ InternalEntityInstance entityInstance, List clearedAttributeNames) {
+ var entity = application.getRequiredEntityByName(entityName);
+ return clearedAttributeNames.stream()
+ .flatMap(name -> entity.getContentAttributes().stream()
+ .filter(ca -> ca.getName().equals(name))
+ .findFirst()
+ .flatMap(ca -> extractContentReference(entityInstance, ca))
+ .stream())
+ .toList();
+ }
+
+ private Optional extractContentReference(InternalEntityInstance entityInstance, ContentAttribute contentAttribute) {
+ return entityInstance.getByAttributeName(contentAttribute.getName(), CompositeAttributeData.class)
+ .flatMap(data -> data.getAttributeByName(contentAttribute.getId().getName()))
+ .filter(SimpleAttributeData.class::isInstance)
+ .map(SimpleAttributeData.class::cast)
+ .map(d -> (String) d.getValue())
+ .map(ContentReference::of);
}
@Override
diff --git a/contentgrid-appserver-domain/src/main/java/com/contentgrid/appserver/domain/data/mapper/ContentUploadAttributeMapper.java b/contentgrid-appserver-domain/src/main/java/com/contentgrid/appserver/domain/data/mapper/ContentUploadAttributeMapper.java
index 5d36c86f8..7fbf814fa 100644
--- a/contentgrid-appserver-domain/src/main/java/com/contentgrid/appserver/domain/data/mapper/ContentUploadAttributeMapper.java
+++ b/contentgrid-appserver-domain/src/main/java/com/contentgrid/appserver/domain/data/mapper/ContentUploadAttributeMapper.java
@@ -4,6 +4,7 @@
import com.contentgrid.appserver.application.model.attributes.ContentAttribute;
import com.contentgrid.appserver.application.model.attributes.SimpleAttribute;
import com.contentgrid.appserver.application.model.values.AttributePath;
+import com.contentgrid.appserver.content.lifecycle.ContentReferenceTracker;
import com.contentgrid.appserver.contentstore.api.ContentStore;
import com.contentgrid.appserver.contentstore.api.UnwritableContentException;
import com.contentgrid.appserver.domain.data.DataEntry;
@@ -26,6 +27,7 @@
public class ContentUploadAttributeMapper extends AbstractDescendingAttributeMapper {
private final ContentStore contentStore;
+ private final ContentReferenceTracker contentReferenceTracker;
@Override
protected Optional mapSimpleAttribute(AttributePath path, SimpleAttribute simpleAttribute, DataEntry inputData) {
@@ -68,6 +70,8 @@ protected Optional mapCompositeAttributeUnsupportedDatatype(Attribute
var inputStream = new CountingInputStream(fileDataEntry.getInputStream());
var contentAccessor = contentStore.writeContent(inputStream);
+ contentReferenceTracker.incrementReference(contentAccessor.getReference());
+
var builder = MapDataEntry.builder();
builder.item(contentAttribute.getId().getName().getValue(), new StringDataEntry(contentAccessor.getReference().getValue()))
.item(contentAttribute.getLength().getName().getValue(), new LongDataEntry(inputStream.getSize()))
diff --git a/contentgrid-appserver-domain/src/main/java/com/contentgrid/appserver/domain/data/validation/ContentAttributeModificationValidator.java b/contentgrid-appserver-domain/src/main/java/com/contentgrid/appserver/domain/data/validation/ContentAttributeModificationValidator.java
index af3e7031e..d76f741f3 100644
--- a/contentgrid-appserver-domain/src/main/java/com/contentgrid/appserver/domain/data/validation/ContentAttributeModificationValidator.java
+++ b/contentgrid-appserver-domain/src/main/java/com/contentgrid/appserver/domain/data/validation/ContentAttributeModificationValidator.java
@@ -2,6 +2,7 @@
import com.contentgrid.appserver.application.model.attributes.Attribute;
import com.contentgrid.appserver.application.model.attributes.ContentAttribute;
+import com.contentgrid.appserver.application.model.values.AttributeName;
import com.contentgrid.appserver.application.model.values.AttributePath;
import com.contentgrid.appserver.domain.data.DataEntry;
import com.contentgrid.appserver.domain.data.DataEntry.MapDataEntry;
@@ -11,6 +12,9 @@
import com.contentgrid.appserver.domain.data.EntityInstance;
import com.contentgrid.appserver.domain.data.InvalidDataException;
import com.contentgrid.appserver.domain.data.validation.AttributeValidationDataMapper.Validator;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -19,6 +23,11 @@
@Slf4j
public class ContentAttributeModificationValidator implements Validator {
private final EntityInstance entityData;
+ private final List dereferencedAttributeNames = new ArrayList<>();
+
+ public List getDereferencedAttributeNames() {
+ return Collections.unmodifiableList(dereferencedAttributeNames);
+ }
@Override
public void validate(AttributePath attributePath, Attribute attribute, DataEntry dataEntry)
@@ -45,8 +54,7 @@ public void validate(AttributePath attributePath, Attribute attribute, DataEntry
}
}
} else if (hasContent && dataEntry instanceof NullDataEntry) {
- // TODO: mark content for deletion, it can only be deleted in ContentStore
- // after database transaction has completed
+ dereferencedAttributeNames.add(attributePath.getFirst());
}
}
diff --git a/contentgrid-appserver-domain/src/test/java/com/contentgrid/appserver/domain/DatamodelApiImplTest.java b/contentgrid-appserver-domain/src/test/java/com/contentgrid/appserver/domain/DatamodelApiImplTest.java
index 30cca285b..1010103e3 100644
--- a/contentgrid-appserver-domain/src/test/java/com/contentgrid/appserver/domain/DatamodelApiImplTest.java
+++ b/contentgrid-appserver-domain/src/test/java/com/contentgrid/appserver/domain/DatamodelApiImplTest.java
@@ -13,6 +13,7 @@
import com.contentgrid.appserver.application.model.values.EntityName;
import com.contentgrid.appserver.application.model.values.RelationName;
import com.contentgrid.appserver.application.model.values.SortableName;
+import com.contentgrid.appserver.content.lifecycle.ContentReferenceTracker;
import com.contentgrid.appserver.contentstore.api.ContentAccessor;
import com.contentgrid.appserver.contentstore.api.ContentReference;
import com.contentgrid.appserver.contentstore.api.ContentStore;
@@ -106,6 +107,8 @@ class DatamodelApiImplTest {
private ContentStore contentStore;
@Mock
private DomainEventDispatcher domainEventDispatcher;
+ @Mock
+ private ContentReferenceTracker contentReferenceTracker;
@Spy
private CursorCodec codec = new RequestIntegrityCheckCursorCodec(new SimplePageBasedCursorCodec());
@@ -142,7 +145,8 @@ void setup() {
contentStore,
domainEventDispatcher,
codec,
- clock
+ clock,
+ contentReferenceTracker
);
}
@@ -1698,4 +1702,143 @@ void deleteNonExistent() {
}
}
+
+ @Nested
+ class ContentReferenceTracking {
+
+ @Test
+ void upload_incrementsReference() throws InvalidPropertyDataException, UnwritableContentException {
+ var entityId = EntityId.of(UUID.randomUUID());
+ var personId = EntityId.of(UUID.randomUUID());
+ var fileId = "uploaded-file.bin";
+ Mockito.when(queryEngine.create(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any()))
+ .thenReturn(EntityData.builder().name(INVOICE.getName()).id(entityId).build());
+ Mockito.when(contentStore.writeContent(Mockito.any())).thenAnswer(contentAccessorFor(fileId));
+
+ datamodelApi.create(APPLICATION, INVOICE.getName(), MapRequestInputData.fromMap(Map.of(
+ "number", "invoice-1",
+ "amount", 1.50,
+ "confidentiality", "public",
+ "content", new FileDataEntry("file.pdf", "application/pdf", inputStreamWithSize(10)),
+ "customer", new RelationDataEntry(PERSON.getName(), personId)
+ )), AuthorizationContext.allowAll());
+
+ Mockito.verify(contentReferenceTracker).incrementReference(ContentReference.of(fileId));
+ }
+
+ @Test
+ void update_contentCleared_decrementsReference() throws InvalidPropertyDataException {
+ var entityId = EntityId.of(UUID.randomUUID());
+ var existingContentId = "existing-content.bin";
+ setupEntityQueryWithContent(existingContentId);
+ var entity = EntityData.builder().name(INVOICE.getName()).id(entityId).build();
+ Mockito.when(queryEngine.update(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any()))
+ .thenReturn(new UpdateResult(entity, entity));
+
+ datamodelApi.update(APPLICATION, EntityRequest.forEntity(INVOICE.getName(), entityId),
+ MapRequestInputData.fromMap(Map.of(
+ "number", "invoice-1",
+ "amount", 1.50,
+ "confidentiality", "public",
+ "content", NullDataEntry.INSTANCE
+ )), AuthorizationContext.allowAll());
+
+ Mockito.verify(contentReferenceTracker).decrementReference(ContentReference.of(existingContentId));
+ }
+
+ @Test
+ void update_contentWasEmpty_doesNotDecrementReference() throws InvalidPropertyDataException {
+ var entityId = EntityId.of(UUID.randomUUID());
+ setupEntityQuery(); // existing entity has no content
+ var entity = EntityData.builder().name(INVOICE.getName()).id(entityId).build();
+ Mockito.when(queryEngine.update(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any()))
+ .thenReturn(new UpdateResult(entity, entity));
+
+ datamodelApi.update(APPLICATION, EntityRequest.forEntity(INVOICE.getName(), entityId),
+ MapRequestInputData.fromMap(Map.of(
+ "number", "invoice-1",
+ "amount", 1.50,
+ "confidentiality", "public",
+ "content", NullDataEntry.INSTANCE
+ )), AuthorizationContext.allowAll());
+
+ Mockito.verifyNoInteractions(contentReferenceTracker);
+ }
+
+ @Test
+ void partialUpdate_contentCleared_decrementsReference() throws InvalidPropertyDataException {
+ var entityId = EntityId.of(UUID.randomUUID());
+ var existingContentId = "existing-content.bin";
+ setupEntityQueryWithContent(existingContentId);
+ var entity = EntityData.builder().name(INVOICE.getName()).id(entityId).build();
+ Mockito.when(queryEngine.update(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any()))
+ .thenReturn(new UpdateResult(entity, entity));
+
+ datamodelApi.updatePartial(APPLICATION, EntityRequest.forEntity(INVOICE.getName(), entityId),
+ MapRequestInputData.fromMap(Map.of(
+ "content", NullDataEntry.INSTANCE
+ )), AuthorizationContext.allowAll());
+
+ Mockito.verify(contentReferenceTracker).decrementReference(ContentReference.of(existingContentId));
+ }
+
+ @Test
+ void deleteEntity_withContent_decrementsReference() {
+ var entityId = EntityId.of(UUID.randomUUID());
+ var contentId = "content-to-delete.bin";
+ var deletedData = EntityData.builder()
+ .name(INVOICE.getName())
+ .id(entityId)
+ .attribute(CompositeAttributeData.builder()
+ .name(INVOICE_CONTENT.getName())
+ .attribute(new SimpleAttributeData<>(INVOICE_CONTENT.getId().getName(), contentId))
+ .build())
+ .build();
+ Mockito.when(queryEngine.delete(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any()))
+ .thenReturn(Optional.of(deletedData));
+
+ datamodelApi.deleteEntity(APPLICATION, EntityRequest.forEntity(INVOICE.getName(), entityId),
+ AuthorizationContext.allowAll());
+
+ Mockito.verify(contentReferenceTracker).decrementReference(ContentReference.of(contentId));
+ }
+
+ @Test
+ void deleteEntity_withoutContent_doesNotDecrementReference() {
+ var entityId = EntityId.of(UUID.randomUUID());
+ var deletedData = EntityData.builder()
+ .name(INVOICE.getName())
+ .id(entityId)
+ .attributes(List.of())
+ .build();
+ Mockito.when(queryEngine.delete(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any()))
+ .thenReturn(Optional.of(deletedData));
+
+ datamodelApi.deleteEntity(APPLICATION, EntityRequest.forEntity(INVOICE.getName(), entityId),
+ AuthorizationContext.allowAll());
+
+ Mockito.verifyNoInteractions(contentReferenceTracker);
+ }
+
+ @Test
+ void deleteEntity_withEmptyContent_doesNotDecrementReference() {
+ var entityId = EntityId.of(UUID.randomUUID());
+ // Content attribute present but id is null (no content was uploaded)
+ var deletedData = EntityData.builder()
+ .name(INVOICE.getName())
+ .id(entityId)
+ .attribute(CompositeAttributeData.builder()
+ .name(INVOICE_CONTENT.getName())
+ .attribute(new SimpleAttributeData<>(INVOICE_CONTENT.getId().getName(), (String) null))
+ .build())
+ .build();
+ Mockito.when(queryEngine.delete(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any()))
+ .thenReturn(Optional.of(deletedData));
+
+ datamodelApi.deleteEntity(APPLICATION, EntityRequest.forEntity(INVOICE.getName(), entityId),
+ AuthorizationContext.allowAll());
+
+ Mockito.verifyNoInteractions(contentReferenceTracker);
+ }
+ }
}
diff --git a/contentgrid-appserver-query-engine-impl-jooq/src/main/java/com/contentgrid/appserver/query/engine/jooq/JOOQTableCreator.java b/contentgrid-appserver-query-engine-impl-jooq/src/main/java/com/contentgrid/appserver/query/engine/jooq/JOOQTableCreator.java
index b7c785bf1..515bdf800 100644
--- a/contentgrid-appserver-query-engine-impl-jooq/src/main/java/com/contentgrid/appserver/query/engine/jooq/JOOQTableCreator.java
+++ b/contentgrid-appserver-query-engine-impl-jooq/src/main/java/com/contentgrid/appserver/query/engine/jooq/JOOQTableCreator.java
@@ -35,6 +35,8 @@ public void createTables(Application application) {
}
// Create extensions schema and functions
createCGPrefixSearchNormalize(dslContext);
+ // Create content lifecycle tracking table
+ createContentReferencesTable(dslContext);
}
private void createTableForEntity(DSLContext dslContext, Entity entity) {
@@ -83,6 +85,25 @@ public void dropTables(Application application) {
// Drop extensions schema and functions
dropCGPrefixSearchNormalize(dslContext);
+ // Drop content lifecycle tracking table
+ dropContentReferencesTable(dslContext);
+ }
+
+ @Allow.PlainSQL
+ private void createContentReferencesTable(DSLContext dslContext) {
+ dslContext.createTableIfNotExists("_content_references")
+ .column(DSL.field("content_id", String.class), org.jooq.impl.SQLDataType.VARCHAR.nullable(false))
+ .column(DSL.field("reference_count", Integer.class), org.jooq.impl.SQLDataType.INTEGER.nullable(false))
+ .column(DSL.field("first_referenced_at", java.time.OffsetDateTime.class), org.jooq.impl.SQLDataType.TIMESTAMPWITHTIMEZONE.nullable(false))
+ .column(DSL.field("last_dereferenced_at", java.time.OffsetDateTime.class), org.jooq.impl.SQLDataType.TIMESTAMPWITHTIMEZONE.nullable(true))
+ .column(DSL.field("marked_for_deletion_at", java.time.OffsetDateTime.class), org.jooq.impl.SQLDataType.TIMESTAMPWITHTIMEZONE.nullable(true))
+ .primaryKey("content_id")
+ .execute();
+ }
+
+ @Allow.PlainSQL
+ private void dropContentReferencesTable(DSLContext dslContext) {
+ dslContext.dropTableIfExists("_content_references").execute();
}
@Allow.PlainSQL
diff --git a/openspec/changes/add-content-lifecycle/.openspec.yaml b/openspec/changes/add-content-lifecycle/.openspec.yaml
new file mode 100644
index 000000000..3184e5abf
--- /dev/null
+++ b/openspec/changes/add-content-lifecycle/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-03-06
diff --git a/openspec/changes/add-content-lifecycle/design.md b/openspec/changes/add-content-lifecycle/design.md
new file mode 100644
index 000000000..778211ce9
--- /dev/null
+++ b/openspec/changes/add-content-lifecycle/design.md
@@ -0,0 +1,141 @@
+## Context
+
+The `ContentStore` stores binary content objects identified by a `ContentReference`. Currently, `ContentStore.remove()`
+is never called in production — content objects accumulate indefinitely. Two additional requirements shape this design
+beyond simple deletion:
+
+1. **Content sharing**: Multiple entities may reference the same content object (same `ContentReference`). Deleting
+ content when one entity is removed must not affect others still pointing to it.
+2. **Grace period**: Content must not be deleted immediately after dereference — a configurable delay (default 7 days)
+ ensures backups are taken before permanent removal.
+
+The domain layer already has all required integration points:
+
+- `ContentUploadAttributeMapper`: writes content to the store (increment point)
+- `DatamodelApiImpl.updatePartial()` / `update()`: entity content replaced or cleared (decrement point)
+- `DatamodelApiImpl.deleteEntity()`: entity removed (decrement point for all content attributes)
+- `ContentAttributeModificationValidator`: detects when a content attribute is being set to null
+
+## Goals / Non-Goals
+
+**Goals:**
+
+- Track reference counts for every content object in a `_content_references` table.
+- Increment on upload; decrement after a successful transaction commit when content is dereferenced.
+- Mark content for deletion when its reference count reaches zero; do not delete immediately.
+- A separately-triggered deletion job verifies references and removes content past the grace period.
+- Support content sharing: multiple entities pointing to the same `ContentReference`.
+
+**Non-Goals:**
+
+- Retroactive cleanup of orphaned content from before this feature.
+- Exposing a content-sharing API (future consideration).
+- Two-phase commit or distributed transaction guarantees between the DB and the content store.
+- Deduplication of identical content on upload.
+
+## Decisions
+
+### Decision 1: Reference counting in a dedicated `_content_references` table
+
+**Chosen:** A new table tracks `content_id`, `reference_count`, `first_referenced_at`, `last_dereferenced_at`, and
+`marked_for_deletion_at`.
+
+**Alternatives considered:**
+
+- *Immediate deletion in the domain layer (no tracking table)*: Doesn't support content sharing or grace periods.
+- *Scan all entity tables on every deletion*: Correct but slow — N queries per deletion where N = number of entity types
+ with content attributes. Acceptable for the deletion job (runs infrequently), not acceptable for request-time use.
+
+**Rationale:** A reference count table makes increment/decrement O(1) at request time. The deletion job uses it to find
+candidates and the per-entity scan is only done as a safety check before final deletion.
+
+### Decision 2: New module `contentgrid-appserver-content-lifecycle`
+
+**Chosen:** All tracking and deletion logic lives in a new module. The domain module gains an optional dependency on it
+via Spring auto-configuration.
+
+**Alternatives considered:**
+
+- *Inline in `contentgrid-appserver-domain`*: Couples database lifecycle concerns with the domain logic; harder to
+ disable or replace.
+
+**Rationale:** Keeping lifecycle tracking in its own module makes it independently deployable. The domain module depends
+on the `ContentReferenceTracker` interface; the implementation is provided by the lifecycle module.
+
+### Decision 3: Decrement-after-commit via `TransactionSynchronizationManager`
+
+**Chosen:** `DeferredContentReferenceTracker` wraps `ContentReferenceTracker` and registers a
+`TransactionSynchronizationAdapter` to call `decrementReference()` in the `afterCommit()` callback.
+
+**Alternatives considered:**
+
+- *Decrement inline (before commit)*: If the transaction rolls back, the decrement would incorrectly reduce the
+ reference count for a content object that is still referenced.
+- *Async event after commit*: Introduces messaging infrastructure for a synchronous concern; adds complexity and failure
+ modes.
+
+**Rationale:** `TransactionSynchronizationManager.afterCommit()` is the standard Spring pattern for "do this only if the
+transaction succeeded." The increment (on upload) does not need deferral because a rolled-back upload is simply an
+orphaned object — it gets cleaned up by the same grace-period job.
+
+### Decision 4: Safety verification before deletion (verified reference counting)
+
+**Chosen:** Before the deletion job removes a content object, it queries every entity table that has content attributes
+to confirm the content ID is truly not referenced anywhere. If drift is detected (count says zero but a live reference
+is found), the deletion marker is cleared and the drift is logged and counted in metrics.
+
+**Alternatives considered:**
+
+- *Trust the reference count, skip verification*: Any count drift (e.g., from a bug, manual DB operation, migration)
+ would cause data loss.
+
+**Rationale:** Content deletion is irreversible. The safety query is cheap (runs infrequently in a batch job, not on
+every request) and prevents silent data loss from drift.
+
+### Decision 5: K8s CronJob for deletion (not a scheduled Spring task)
+
+**Chosen:** A `ContentDeletionJob` is a one-shot Spring Boot job triggered by a K8s CronJob.
+
+**Alternatives considered:**
+
+- *`@Scheduled` inside the running app*: Runs in every replica; requires leader election to avoid duplicate deletions;
+ harder to observe and operate independently.
+
+**Rationale:** A separate K8s CronJob runs in a single pod, has full application context (DB + ContentStore), is
+independently scalable, and is easy to trigger or disable without redeploying the main app.
+
+## Risks / Trade-offs
+
+- **Count drift from bugs or manual DB changes** → Safety verification in the deletion job catches this; drift is logged
+ with metrics for alerting. No data loss occurs, but the content is not deleted until drift is resolved.
+- **Increment on upload has no rollback** → If an upload transaction rolls back after `writeContent()` but before the
+ increment is persisted, there is an orphaned content object with no tracking entry. The safety query will find no
+ references to it and the deletion job can clean it up (the missing reference count entry is treated as "no references,
+ safe to delete" only after the grace period — but without a `marked_for_deletion_at` it would never be found). *
+ *Mitigation:** The increment should be persisted in the same transaction as the entity write, so both commit or both
+ roll back together.
+- **Race between decrement and CronJob** → If a CronJob run coincides with a decrement that hasn't fully committed yet,
+ the safety check catches it.
+- **Grace period delays storage reclamation** → By design; default 7 days. Configurable.
+
+## Migration Plan
+
+1. Deploy the new module. `JOOQTableCreator.createTables()` now also creates the `_content_references` table (via the
+ JOOQ DSL — no Flyway migration; schema management is handled by an external project that calls `createTables()`).
+2. Run the backfill SQL in the external schema management project to populate reference counts from existing entity data:
+ ```sql
+ INSERT INTO _content_references (content_id, reference_count, first_referenced_at)
+ SELECT content_id, COUNT(*), NOW()
+ FROM (
+ -- one UNION ALL per entity/attribute combination with a content reference
+ ) AS all_refs
+ GROUP BY content_id;
+ ```
+3. Enable the deletion job (`contentgrid.content.lifecycle.deletion.enabled: true`) after the backfill has been
+ verified.
+
+## Open Questions
+
+- Should content upload increment be in the same JOOQ transaction as the entity write, or in a separate transaction? (
+ Affects the rollback-on-upload risk above.)
+- Is there a need to expose the `_content_references` table via an actuator endpoint for operational visibility?
diff --git a/openspec/changes/add-content-lifecycle/proposal.md b/openspec/changes/add-content-lifecycle/proposal.md
new file mode 100644
index 000000000..39666f284
--- /dev/null
+++ b/openspec/changes/add-content-lifecycle/proposal.md
@@ -0,0 +1,40 @@
+## Why
+
+When content is uploaded, it is written to the `ContentStore` and a `ContentReference` is stored in the entity. However,
+when that content is later replaced, cleared from an attribute, or the owning entity is deleted, the underlying storage
+bytes are never removed — causing unbounded storage growth. Additionally, the system needs to support multiple entities
+pointing to the same content object (content sharing), and deletions must not be immediate: a configurable grace period
+is required so that backups can be made before content is permanently removed.
+
+## What Changes
+
+- A new `contentgrid-appserver-content-lifecycle` module is introduced to own all content lifecycle tracking and
+ deletion logic.
+- A `_content_references` table tracks reference counts, first-referenced time, last-dereferenced time, and deletion
+ markers for every content object.
+- When content is uploaded, its reference count is incremented. When content is dereferenced (replaced, cleared, or
+ entity deleted), its reference count is decremented — but only after the database transaction commits successfully.
+- When the reference count reaches zero, the content is marked for deletion with a timestamp. It is **not** deleted
+ immediately.
+- A K8s CronJob runs the `ContentDeletionJob`, which finds content past the grace period, performs a safety verification
+ against all entity tables, and only then deletes from the `ContentStore`.
+- **BREAKING**: `contentgrid-appserver-domain` gains a required dependency on the content lifecycle module.
+
+## Capabilities
+
+### New Capabilities
+
+- `content-lifecycle`: Reference-counted tracking of content objects, grace-period-based deletion scheduling, and a
+ verified deletion job.
+
+### Modified Capabilities
+
+## Impact
+
+- `contentgrid-appserver-content-lifecycle`: New module with `ContentReferenceTracker`, `JooqContentReferenceTracker`,
+ `DeferredContentReferenceTracker`, `ContentReferenceVerificationQuery`, `ContentDeletionJob`, and auto-configuration.
+- `contentgrid-appserver-domain`: `ContentUploadAttributeMapper` increments on upload; `DatamodelApiImpl` decrements on
+ entity delete; the content cleared path decrements via `ContentAttributeModificationValidator`.
+- `settings.gradle`: New module registered.
+- `contentgrid-appserver-autoconfigure`: Auto-configuration registered.
+- Content store implementations (S3, filesystem): `ContentStore.remove()` will now be called by the deletion job.
diff --git a/openspec/changes/add-content-lifecycle/specs/content-lifecycle/spec.md b/openspec/changes/add-content-lifecycle/specs/content-lifecycle/spec.md
new file mode 100644
index 000000000..93a3af521
--- /dev/null
+++ b/openspec/changes/add-content-lifecycle/specs/content-lifecycle/spec.md
@@ -0,0 +1,77 @@
+## ADDED Requirements
+
+### Requirement: Reference count is incremented when content is uploaded
+When a new content object is written to the `ContentStore`, the system SHALL create a new entry in `_content_references` with `reference_count = 1`. Each upload always produces a unique `ContentReference`, so an existing entry will never be found at upload time.
+
+#### Scenario: New content uploaded creates reference entry
+- **WHEN** a file is uploaded to a content attribute on an entity
+- **THEN** a new row is created in `_content_references` for that `content_id` with `reference_count = 1`
+- **AND** `first_referenced_at` is set to the current time
+
+### Requirement: Reference count is decremented after successful transaction when content is dereferenced
+When a content attribute is replaced, cleared, or its owning entity is deleted, the system SHALL decrement the reference count for the old `ContentReference` in `_content_references`, but only after the database transaction has committed successfully.
+
+#### Scenario: Content replaced decrements old reference after commit
+- **WHEN** a content attribute is updated with a new file upload
+- **AND** the transaction commits successfully
+- **THEN** the reference count for the old `ContentReference` is decremented by 1
+
+#### Scenario: Content cleared decrements reference after commit
+- **WHEN** a content attribute is set to null
+- **AND** the transaction commits successfully
+- **THEN** the reference count for the old `ContentReference` is decremented by 1
+
+#### Scenario: Entity deleted decrements all content references after commit
+- **WHEN** an entity with one or more populated content attributes is deleted
+- **AND** the transaction commits successfully
+- **THEN** the reference count for each content attribute's `ContentReference` is decremented by 1
+
+#### Scenario: Transaction rollback suppresses decrement
+- **WHEN** an operation that would dereference content is attempted
+- **AND** the database transaction rolls back
+- **THEN** no decrement is applied to `_content_references`
+- **AND** the reference count remains unchanged
+
+#### Scenario: No decrement when attribute had no prior content
+- **WHEN** a content attribute that currently holds no content is updated
+- **THEN** no decrement is attempted
+
+### Requirement: Content is marked for deletion when its reference count reaches zero
+When a decrement causes the `reference_count` in `_content_references` to reach zero, the system SHALL set `marked_for_deletion_at` to the current timestamp. The content object SHALL NOT be removed from the `ContentStore` at this point.
+
+#### Scenario: Content marked for deletion when last reference is removed
+- **WHEN** the reference count for a `ContentReference` is decremented to zero
+- **THEN** `marked_for_deletion_at` is set to the current timestamp
+- **AND** the content object is still readable from the `ContentStore`
+
+### Requirement: Content past the grace period is deleted by the deletion job
+The `ContentDeletionJob` SHALL find all `_content_references` entries where `marked_for_deletion_at` is older than the configured grace period, verify they are truly unreferenced, and delete them from the `ContentStore`.
+
+#### Scenario: Orphaned content past grace period is deleted
+- **WHEN** the `ContentDeletionJob` runs
+- **AND** a `_content_references` entry has `marked_for_deletion_at` older than the grace period
+- **AND** the safety verification confirms the `content_id` is not referenced in any entity table
+- **THEN** the content object is removed from the `ContentStore`
+- **AND** the row is removed from `_content_references`
+- **AND** the `content.deletion.success` metric is incremented
+
+#### Scenario: Content within the grace period is not deleted
+- **WHEN** the `ContentDeletionJob` runs
+- **AND** a `_content_references` entry has `marked_for_deletion_at` set but within the grace period
+- **THEN** the content object is NOT deleted
+
+#### Scenario: Content still referenced is not deleted (drift protection)
+- **WHEN** the `ContentDeletionJob` runs
+- **AND** a `_content_references` entry has `marked_for_deletion_at` set past the grace period
+- **AND** the safety verification finds the `content_id` is still referenced in an entity table
+- **THEN** the content object is NOT deleted
+- **AND** `marked_for_deletion_at` is cleared
+- **AND** the `content.deletion.drift` metric is incremented
+- **AND** a warning is logged with the `content_id`
+
+#### Scenario: Deletion job continues after individual deletion failure
+- **WHEN** the `ContentDeletionJob` runs and `ContentStore.remove()` throws for one content object
+- **THEN** the deletion is attempted for all remaining candidates
+- **AND** the `content.deletion.failure` metric is incremented for each failure
+- **AND** each failure is logged with the `content_id`
+
diff --git a/openspec/changes/add-content-lifecycle/tasks.md b/openspec/changes/add-content-lifecycle/tasks.md
new file mode 100644
index 000000000..6c28b3b82
--- /dev/null
+++ b/openspec/changes/add-content-lifecycle/tasks.md
@@ -0,0 +1,84 @@
+## 1. New Module Scaffolding
+
+- [x] 1.1 Create `contentgrid-appserver-content-lifecycle` module directory and `build.gradle` with dependencies on
+ `contentgrid-appserver-contentstore-api`, `spring-boot-starter`, and `jooq`
+- [x] 1.2 Register the new module in `settings.gradle`
+- [x] 1.3 Add the module as a required dependency in `contentgrid-appserver-domain/build.gradle`
+
+## 2. Database Schema
+
+- [x] 2.1 Add `createContentReferencesTable(DSLContext)` and `dropContentReferencesTable(DSLContext)` methods to
+ `JOOQTableCreator`, using the JOOQ DSL to create/drop the `_content_references` table (`content_id VARCHAR PRIMARY KEY`,
+ `reference_count INTEGER NOT NULL`, `first_referenced_at TIMESTAMP NOT NULL`, `last_dereferenced_at TIMESTAMP`,
+ `marked_for_deletion_at TIMESTAMP`); call them from `createTables()` and `dropTables()`
+- [x] 2.2 Define `_content_references` table and field references as `DSL.table()` / `DSL.field()` constants in the
+ new lifecycle module (no codegen — follow the same inline DSL pattern used elsewhere in `contentgrid-appserver-query-engine-impl-jooq`)
+
+## 3. ContentReferenceTracker Interface and Implementations
+
+- [x] 3.1 Define the `ContentReferenceTracker` interface in the new module with `incrementReference(ContentReference)`
+ and `decrementReference(ContentReference)` methods
+- [x] 3.2 Implement `JooqContentReferenceTracker`: `incrementReference` upserts a row (insert or increment count, clear
+ `marked_for_deletion_at`); `decrementReference` decrements count and sets `marked_for_deletion_at = NOW()` when count
+ reaches zero, sets `last_dereferenced_at`
+- [x] 3.3 Implement `DeferredContentReferenceTracker` that delegates `incrementReference` directly and registers
+ `decrementReference` as a `TransactionSynchronizationManager.afterCommit()` callback
+- [x] 3.4 Write unit tests for `JooqContentReferenceTracker` covering: first increment (creates row), second increment (
+ increments count, clears deletion marker), decrement to non-zero (only decrements), decrement to zero (sets
+ `marked_for_deletion_at`)
+- [x] 3.5 Write unit tests for `DeferredContentReferenceTracker` verifying that decrement is not called when the
+ transaction rolls back
+
+## 4. Domain Integration — Upload (Increment)
+
+- [x] 4.1 Inject `ContentReferenceTracker` into `ContentUploadAttributeMapper`
+- [x] 4.2 After `contentStore.writeContent()` succeeds, call `contentReferenceTracker.incrementReference()` with the new
+ `ContentReference`
+- [x] 4.3 Write tests verifying increment is called on upload
+
+## 5. Domain Integration — Content Cleared (Decrement)
+
+- [x] 5.1 Update `ContentAttributeModificationValidator` to collect content IDs that are being set to null; expose
+ them (e.g., return them from `validate()` or accumulate in a field)
+- [x] 5.2 In `DatamodelApiImpl.update()` and `updatePartial()`, after the query engine operation, call
+ `contentReferenceTracker.decrementReference()` for each collected dereferenced content ID
+- [x] 5.3 Write tests verifying decrement is called when content is cleared and not called when attribute was previously
+ empty
+
+## 6. Domain Integration — Entity Delete (Decrement)
+
+- [x] 6.1 Add a helper in `DatamodelApiImpl` that extracts all non-null `ContentReference` values from an
+ `InternalEntityInstance` by scanning the `Application` model for `ContentAttribute` instances
+- [x] 6.2 In `DatamodelApiImpl.deleteEntity()`, after `queryEngine.delete()` returns the deleted entity, call
+ `contentReferenceTracker.decrementReference()` for each extracted content reference
+- [x] 6.3 Write tests for the delete path: entity with multiple content attributes (all decremented), entity with no
+ content attributes (no decrement), entity with mix of populated and empty content attributes
+
+## 7. ContentDeletionJob
+
+- [x] 7.1 Implement `ContentReferenceVerificationQuery` that, given a `content_id`, queries all entity tables with
+ content attributes and returns true if any reference exists
+- [x] 7.2 Implement `ContentDeletionJob`: query `_content_references` where
+ `marked_for_deletion_at <= NOW() - grace_period` (batch-limited); for each candidate, run the safety check; if
+ unreferenced delete from `ContentStore` and remove the row, if referenced clear the mark and record drift
+- [x] 7.3 Record metrics: `content.deletion.success`, `content.deletion.failure`, `content.deletion.drift`
+- [x] 7.4 Expose the job as a Spring Boot `CommandLineRunner` or `ApplicationRunner` for K8s CronJob invocation
+- [x] 7.5 Write unit tests for `ContentDeletionJob`: candidate past grace period and unreferenced (deleted), candidate
+ within grace period (skipped), candidate with drift detected (marker cleared, metric incremented), failure in
+ `ContentStore.remove()` (continues to next candidate)
+
+## 8. Configuration and Auto-Configuration
+
+- [x] 8.1 Define `ContentLifecycleProperties` with `deletion.enabled` (default `true`),
+ `deletion.grace-period` (default `P7D`), `deletion.batch-size` (default `100`)
+- [x] 8.2 Write `ContentLifecycleAutoConfiguration` that creates `JooqContentReferenceTracker`,
+ `DeferredContentReferenceTracker`, `ContentReferenceVerificationQuery`, and `ContentDeletionJob` beans
+- [x] 8.3 Register the auto-configuration in
+ `contentgrid-appserver-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`
+
+## 9. Migration Script for Existing Data
+
+- [x] 9.1 Document the backfill SQL pattern (UNION ALL across all entity content columns) in the module README or a
+ runbook
+- [x] 9.2 Consider whether the backfill should be an optional Flyway migration or a manual operation; document the
+ chosen approach
diff --git a/openspec/config.yaml b/openspec/config.yaml
new file mode 100644
index 000000000..392946c67
--- /dev/null
+++ b/openspec/config.yaml
@@ -0,0 +1,20 @@
+schema: spec-driven
+
+# Project context (optional)
+# This is shown to AI when creating artifacts.
+# Add your tech stack, conventions, style guides, domain knowledge, etc.
+# Example:
+# context: |
+# Tech stack: TypeScript, React, Node.js
+# We use conventional commits
+# Domain: e-commerce platform
+
+# Per-artifact rules (optional)
+# Add custom rules for specific artifacts.
+# Example:
+# rules:
+# proposal:
+# - Keep proposals under 500 words
+# - Always include a "Non-goals" section
+# tasks:
+# - Break tasks into chunks of max 2 hours
diff --git a/settings.gradle b/settings.gradle
index 7b91ccccb..7552ead8c 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -43,5 +43,6 @@ include 'contentgrid-appserver-contentstore-impl-fs'
include 'contentgrid-appserver-contentstore-impl-s3'
include 'contentgrid-appserver-contentstore-impl-encryption'
include 'contentgrid-appserver-webjars'
+include 'contentgrid-appserver-content-lifecycle'
include 'contentgrid-appserver-spring-boot-starter'
include 'contentgrid-appserver-integration-test'