HypothesisService.java
package com.taxonomy.versioning.service;
import com.taxonomy.catalog.model.TaxonomyNode;
import com.taxonomy.catalog.repository.TaxonomyNodeRepository;
import com.taxonomy.catalog.service.TaxonomyRelationService;
import com.taxonomy.dto.RelationHypothesisDto;
import com.taxonomy.dsl.mapper.AstToModelMapper;
import com.taxonomy.dsl.mapper.ModelToAstMapper;
import com.taxonomy.dsl.model.ArchitectureElement;
import com.taxonomy.dsl.model.ArchitectureRelation;
import com.taxonomy.dsl.model.CanonicalArchitectureModel;
import com.taxonomy.dsl.model.TaxonomyRootTypes;
import com.taxonomy.dsl.parser.TaxDslParser;
import com.taxonomy.dsl.serializer.TaxDslSerializer;
import com.taxonomy.dsl.storage.DslGitRepository;
import com.taxonomy.dsl.storage.DslGitRepositoryFactory;
import com.taxonomy.dsl.validation.DslValidationResult;
import com.taxonomy.dsl.validation.DslValidator;
import com.taxonomy.model.HypothesisStatus;
import com.taxonomy.model.RelationType;
import com.taxonomy.relations.model.RelationEvidence;
import com.taxonomy.relations.model.RelationHypothesis;
import com.taxonomy.relations.repository.RelationEvidenceRepository;
import com.taxonomy.relations.repository.RelationHypothesisRepository;
import com.taxonomy.workspace.model.SystemRepository;
import com.taxonomy.workspace.model.UserWorkspace;
import com.taxonomy.workspace.repository.UserWorkspaceRepository;
import com.taxonomy.workspace.service.RepositoryContext;
import com.taxonomy.workspace.service.RepositoryScope;
import com.taxonomy.workspace.service.SystemRepositoryService;
import com.taxonomy.workspace.service.WorkspaceContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.UUID;
/**
* Manages the lifecycle of repository/workspace-scoped relation hypotheses.
*
* <p>Explicit {@link RepositoryContext} methods are authoritative. Historic
* {@link WorkspaceContext} overloads retain the exact selected repository and
* resolve workspace provenance through the repository catalog. Only the explicit
* legacy repository sentinel may fall back to the configured primary repository.</p>
*/
@Service
public class HypothesisService {
private static final Logger log = LoggerFactory.getLogger(HypothesisService.class);
private static final String TEST_REPOSITORY_ID = "test-primary";
private final RelationHypothesisRepository hypothesisRepository;
private final RelationEvidenceRepository evidenceRepository;
private final TaxonomyRelationService relationService;
private final TaxonomyNodeRepository nodeRepository;
private final DslGitRepositoryFactory repositoryFactory;
private final SystemRepositoryService systemRepositoryService;
private final UserWorkspaceRepository userWorkspaceRepository;
private final ModelToAstMapper modelToAstMapper = new ModelToAstMapper();
private final AstToModelMapper astToModelMapper = new AstToModelMapper();
private final TaxDslSerializer serializer = new TaxDslSerializer();
private final TaxDslParser parser = new TaxDslParser();
private final DslValidator validator = new DslValidator();
@Autowired
public HypothesisService(RelationHypothesisRepository hypothesisRepository,
RelationEvidenceRepository evidenceRepository,
TaxonomyRelationService relationService,
TaxonomyNodeRepository nodeRepository,
DslGitRepositoryFactory repositoryFactory,
SystemRepositoryService systemRepositoryService,
UserWorkspaceRepository userWorkspaceRepository) {
this.hypothesisRepository = hypothesisRepository;
this.evidenceRepository = evidenceRepository;
this.relationService = relationService;
this.nodeRepository = nodeRepository;
this.repositoryFactory = repositoryFactory;
this.systemRepositoryService = systemRepositoryService;
this.userWorkspaceRepository = userWorkspaceRepository;
}
/** Test-only compatibility constructor for isolated mapper/DSL tests. */
public HypothesisService(RelationHypothesisRepository hypothesisRepository,
RelationEvidenceRepository evidenceRepository,
TaxonomyRelationService relationService,
TaxonomyNodeRepository nodeRepository,
DslGitRepositoryFactory repositoryFactory) {
this(hypothesisRepository, evidenceRepository, relationService, nodeRepository,
repositoryFactory, null, null);
}
/** Persist provisional hypotheses and version their canonical DSL immediately. */
@Transactional
public List<RelationHypothesis> persistFromAnalysis(
List<RelationHypothesisDto> hypotheses,
String sessionId,
RepositoryContext context) {
return persistFromAnalysis(hypotheses, sessionId, context, false);
}
/**
* Persist provisional hypotheses in the surrounding transaction and publish
* their canonical Git DSL only after that transaction has committed.
*
* <p>This variant is the durable worker boundary: a snapshot or claim
* finalization rollback must never leave a Git commit for database state that
* was not committed.</p>
*/
@Transactional
public List<RelationHypothesis> persistFromAnalysisAfterCommit(
List<RelationHypothesisDto> hypotheses,
String sessionId,
RepositoryContext context) {
return persistFromAnalysis(hypotheses, sessionId, context, true);
}
private List<RelationHypothesis> persistFromAnalysis(
List<RelationHypothesisDto> hypotheses,
String sessionId,
RepositoryContext context,
boolean publishAfterCommit) {
if (hypotheses == null || hypotheses.isEmpty()) {
return List.of();
}
RepositoryContext tenant = requireWritableContext(context);
String effectiveSessionId = normalizeSessionId(sessionId);
List<RelationHypothesis> persisted = new ArrayList<>();
for (RelationHypothesisDto dto : hypotheses) {
RelationType relationType = RelationType.valueOf(
requireText(dto.getRelationType(), "relationType").toUpperCase(Locale.ROOT));
String sourceCode = requireText(dto.getSourceCode(), "sourceCode");
String targetCode = requireText(dto.getTargetCode(), "targetCode");
if (hypothesisRepository.existsInRepositoryWorkspaceSession(
tenant.repositoryId(),
RelationHypothesis.scopeKeyFor(tenant.workspaceId()),
RelationHypothesis.sessionScopeKeyFor(effectiveSessionId),
sourceCode,
targetCode,
relationType)) {
continue;
}
RelationHypothesis entity = new RelationHypothesis();
entity.setRepositoryId(tenant.repositoryId());
entity.setSourceNodeId(sourceCode);
entity.setTargetNodeId(targetCode);
entity.setRelationType(relationType);
entity.setConfidence(dto.getConfidence());
entity.setStatus(HypothesisStatus.PROVISIONAL);
entity.setAnalysisSessionId(effectiveSessionId);
entity.setWorkspaceId(tenant.workspaceId());
entity.setOwnerUsername(tenant.username());
RelationHypothesis saved = hypothesisRepository.save(entity);
persisted.add(saved);
if (dto.getReasoning() != null && !dto.getReasoning().isBlank()) {
RelationEvidence evidence = new RelationEvidence();
evidence.setHypothesis(saved);
evidence.setEvidenceType("analysis-rule");
evidence.setSummary(dto.getReasoning());
evidence.setConfidence(dto.getConfidence());
evidenceRepository.save(evidence);
}
}
if (!persisted.isEmpty()) {
if (publishAfterCommit) {
publishHypothesesAfterCommit(
persisted, effectiveSessionId, tenant);
} else {
commitHypothesesAsDsl(
persisted, effectiveSessionId, tenant);
}
}
log.info("Persisted {} hypotheses for session {} in repository {} workspace {}",
persisted.size(), effectiveSessionId,
tenant.repositoryId(), tenant.workspaceId());
return persisted;
}
/** Historic workspace-only overload, resolved through catalog provenance. */
@Transactional
public List<RelationHypothesis> persistFromAnalysis(
List<RelationHypothesisDto> hypotheses,
String sessionId,
WorkspaceContext workspaceContext) {
return persistFromAnalysis(
hypotheses, sessionId, resolveLegacyContext(workspaceContext));
}
/** Historic workspace-only overload with commit-bound Git publication. */
@Transactional
public List<RelationHypothesis> persistFromAnalysisAfterCommit(
List<RelationHypothesisDto> hypotheses,
String sessionId,
WorkspaceContext workspaceContext) {
return persistFromAnalysisAfterCommit(
hypotheses, sessionId, resolveLegacyContext(workspaceContext));
}
@Transactional
public RelationHypothesis accept(Long hypothesisId, RepositoryContext context) {
RepositoryContext tenant = requireWritableContext(context);
RelationHypothesis hypothesis = requireWritableHypothesis(hypothesisId, tenant);
if (hypothesis.getStatus() == HypothesisStatus.ACCEPTED) {
throw new IllegalStateException("Hypothesis " + hypothesisId + " is already ACCEPTED");
}
if (hypothesis.getStatus() == HypothesisStatus.REJECTED) {
throw new IllegalStateException("Hypothesis " + hypothesisId + " is already REJECTED");
}
boolean relationCreated = false;
if (nodeRepository.findByCode(hypothesis.getSourceNodeId()).isPresent()
&& nodeRepository.findByCode(hypothesis.getTargetNodeId()).isPresent()) {
relationService.createRelationInContext(
hypothesis.getSourceNodeId(),
hypothesis.getTargetNodeId(),
hypothesis.getRelationType(),
"Accepted from hypothesis " + hypothesisId,
"hypothesis-accepted",
tenant);
relationCreated = true;
} else {
log.warn("Could not create relation for hypothesis {}: source or target node not found",
hypothesisId);
}
hypothesis.setStatus(HypothesisStatus.ACCEPTED);
hypothesisRepository.save(hypothesis);
commitHypothesesAsDsl(List.of(hypothesis), "accepted-" + hypothesisId, tenant);
log.info("Accepted hypothesis {} in repository {} workspace {}: {} --[{}]--> {} "
+ "(relation created: {})",
hypothesisId, tenant.repositoryId(), tenant.workspaceId(),
hypothesis.getSourceNodeId(), hypothesis.getRelationType(),
hypothesis.getTargetNodeId(), relationCreated);
return hypothesis;
}
@Transactional
public RelationHypothesis accept(Long hypothesisId, WorkspaceContext workspaceContext) {
return accept(hypothesisId, resolveLegacyContext(workspaceContext));
}
@Transactional
public RelationHypothesis reject(Long hypothesisId, RepositoryContext context) {
RepositoryContext tenant = requireWritableContext(context);
RelationHypothesis hypothesis = requireWritableHypothesis(hypothesisId, tenant);
if (hypothesis.getStatus() == HypothesisStatus.ACCEPTED) {
throw new IllegalStateException("Hypothesis " + hypothesisId + " is already ACCEPTED");
}
if (hypothesis.getStatus() == HypothesisStatus.REJECTED) {
throw new IllegalStateException("Hypothesis " + hypothesisId + " is already REJECTED");
}
hypothesis.setStatus(HypothesisStatus.REJECTED);
return hypothesisRepository.save(hypothesis);
}
@Transactional
public RelationHypothesis reject(Long hypothesisId, WorkspaceContext workspaceContext) {
return reject(hypothesisId, resolveLegacyContext(workspaceContext));
}
@Transactional
public RelationHypothesis applyForSession(Long hypothesisId, RepositoryContext context) {
RepositoryContext tenant = requireWritableContext(context);
RelationHypothesis hypothesis = requireWritableHypothesis(hypothesisId, tenant);
hypothesis.setAppliedInCurrentAnalysis(true);
return hypothesisRepository.save(hypothesis);
}
@Transactional
public RelationHypothesis applyForSession(
Long hypothesisId, WorkspaceContext workspaceContext) {
return applyForSession(hypothesisId, resolveLegacyContext(workspaceContext));
}
@Transactional(readOnly = true)
public List<RelationHypothesis> findByStatus(
HypothesisStatus status, RepositoryContext context) {
RepositoryContext tenant = requireContext(context);
return tenant.workspaceId() == null
? hypothesisRepository.findCentralByRepositoryAndStatus(
tenant.repositoryId(), status)
: hypothesisRepository.findVisibleByRepositoryAndWorkspaceAndStatus(
tenant.repositoryId(), tenant.workspaceId(), status);
}
@Transactional(readOnly = true)
public List<RelationHypothesis> findByStatus(
HypothesisStatus status, WorkspaceContext workspaceContext) {
return findByStatus(status, resolveLegacyContext(workspaceContext));
}
@Transactional(readOnly = true)
public List<RelationHypothesis> findAll(RepositoryContext context) {
RepositoryContext tenant = requireContext(context);
return tenant.workspaceId() == null
? hypothesisRepository.findCentralByRepository(tenant.repositoryId())
: hypothesisRepository.findVisibleByRepositoryAndWorkspace(
tenant.repositoryId(), tenant.workspaceId());
}
@Transactional(readOnly = true)
public List<RelationHypothesis> findAll(WorkspaceContext workspaceContext) {
return findAll(resolveLegacyContext(workspaceContext));
}
@Transactional(readOnly = true)
public List<RelationEvidence> findEvidence(
Long hypothesisId, RepositoryContext context) {
requireReadableHypothesis(hypothesisId, requireContext(context));
return evidenceRepository.findByHypothesisId(hypothesisId);
}
@Transactional(readOnly = true)
public List<RelationEvidence> findEvidence(
Long hypothesisId, WorkspaceContext workspaceContext) {
return findEvidence(hypothesisId, resolveLegacyContext(workspaceContext));
}
private RelationHypothesis requireReadableHypothesis(
Long hypothesisId, RepositoryContext context) {
return hypothesisRepository.findByIdVisibleInRepositoryWorkspace(
context.repositoryId(), hypothesisId, context.workspaceId())
.orElseThrow(() -> hypothesisNotFound(hypothesisId));
}
private RelationHypothesis requireWritableHypothesis(
Long hypothesisId, RepositoryContext context) {
return hypothesisRepository.findByIdInRepositoryWorkspace(
context.repositoryId(), hypothesisId, context.workspaceId())
.orElseThrow(() -> hypothesisNotFound(hypothesisId));
}
private IllegalArgumentException hypothesisNotFound(Long hypothesisId) {
return new IllegalArgumentException("Hypothesis not found: " + hypothesisId);
}
private void publishHypothesesAfterCommit(
List<RelationHypothesis> hypotheses,
String sessionId,
RepositoryContext context) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
throw new IllegalStateException(
"Commit-bound hypothesis publication requires active transaction synchronization");
}
List<RelationHypothesis> committedHypotheses = List.copyOf(hypotheses);
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
try {
commitHypothesesAsDsl(
committedHypotheses, sessionId, context);
} catch (RuntimeException failure) {
// The database is already authoritative and committed.
// Keep the snapshot valid, but make the projection gap
// operationally visible for reconciliation.
log.error("Failed to publish committed hypotheses for session {} "
+ "in repository {} workspace {}",
sessionId,
context.repositoryId(),
context.workspaceId(),
failure);
}
}
});
}
private void commitHypothesesAsDsl(
List<RelationHypothesis> hypotheses,
String sessionId,
RepositoryContext context) {
String dslText = generateCanonicalDsl(hypotheses, sessionId);
String branch = hypotheses.stream()
.anyMatch(h -> h.getStatus() == HypothesisStatus.ACCEPTED)
? "accepted" : "draft";
DslGitRepository repository = repositoryFactory.resolveRepository(context);
try {
String commitId = repository.commitDsl(
branch,
dslText,
context.username(),
"Auto-generated from analysis session " + sessionId);
log.info("Committed {} hypotheses as canonical DSL to repository {} workspace {} "
+ "branch '{}': {}",
hypotheses.size(), context.repositoryId(), context.workspaceId(),
branch, commitId);
} catch (IOException e) {
throw new IllegalStateException("Failed to commit canonical hypothesis DSL", e);
}
}
private String generateCanonicalDsl(List<RelationHypothesis> hypotheses, String sessionId) {
CanonicalArchitectureModel model = new CanonicalArchitectureModel();
Set<String> declaredElements = new LinkedHashSet<>();
for (RelationHypothesis hypothesis : hypotheses) {
addElement(model, declaredElements, hypothesis.getSourceNodeId());
addElement(model, declaredElements, hypothesis.getTargetNodeId());
ArchitectureRelation relation = new ArchitectureRelation(
hypothesis.getSourceNodeId(),
hypothesis.getRelationType().name(),
hypothesis.getTargetNodeId());
relation.setStatus(hypothesis.getStatus().name().toLowerCase(Locale.ROOT));
relation.setConfidence(hypothesis.getConfidence());
relation.setProvenance("analysis-session:" + sessionId);
model.getRelations().add(relation);
}
assertValid(model, "generated hypothesis model");
String text = serializer.serialize(modelToAstMapper.toDocument(model, "hypothesis-auto"));
CanonicalArchitectureModel roundTripped = astToModelMapper.map(
parser.parse(text, "hypotheses.taxdsl"));
assertValid(roundTripped, "round-tripped hypothesis DSL");
if (roundTripped.getElements().size() != model.getElements().size()
|| roundTripped.getRelations().size() != model.getRelations().size()) {
throw new IllegalStateException(
"Canonical hypothesis DSL round-trip lost elements or relations");
}
return text;
}
private void addElement(CanonicalArchitectureModel model,
Set<String> declaredElements,
String code) {
if (!declaredElements.add(code)) {
return;
}
TaxonomyNode node = nodeRepository.findByCode(code).orElse(null);
String root = resolveRoot(code, node);
ArchitectureElement element = new ArchitectureElement();
element.setId(code);
element.setType(TaxonomyRootTypes.typeFor(root));
element.setTaxonomy(root);
element.setTitle(node != null && node.getNameEn() != null && !node.getNameEn().isBlank()
? node.getNameEn() : code);
if (node != null) {
element.setDescription(node.getDescriptionEn());
}
model.getElements().add(element);
}
private String resolveRoot(String code, TaxonomyNode node) {
if (node != null && node.getTaxonomyRoot() != null) {
return node.getTaxonomyRoot();
}
if (TaxonomyRootTypes.ROOT_TO_TYPE.containsKey(code)) {
return code;
}
String root = TaxonomyRootTypes.rootFromId(code);
return root != null ? root : "Unknown";
}
private void assertValid(CanonicalArchitectureModel model, String description) {
DslValidationResult result = validator.validate(model);
if (!result.isValid()) {
throw new IllegalStateException(description + " is invalid: " + result);
}
if (result.hasWarnings()) {
log.warn("{} contains validation warnings: {}", description, result.getWarnings());
}
}
private RepositoryContext resolveLegacyContext(WorkspaceContext workspaceContext) {
WorkspaceContext legacy = workspaceContext != null
? workspaceContext : WorkspaceContext.SHARED;
String username = normalizeUsername(legacy.username());
String branch = normalizeBranch(legacy.currentBranch());
String workspaceId = normalizeOptional(legacy.workspaceId());
String requestedRepositoryId = requireText(
legacy.repositoryId(), "workspaceContext.repositoryId");
boolean legacyRepositorySelection = WorkspaceContext.LEGACY_REPOSITORY_ID
.equals(requestedRepositoryId);
// Isolated mapper tests use the five-argument constructor. Preserve the
// historic fixture repository only for the explicit legacy sentinel.
if (systemRepositoryService == null || userWorkspaceRepository == null) {
String repositoryId = legacyRepositorySelection
? TEST_REPOSITORY_ID : requestedRepositoryId;
String effectiveBranch = branch != null ? branch : "draft";
return workspaceId == null
? RepositoryContext.centralRead(
repositoryId, effectiveBranch, username)
: RepositoryContext.workspace(
repositoryId, workspaceId, effectiveBranch, username);
}
if (workspaceId == null) {
SystemRepository selectedRepository = legacyRepositorySelection
? systemRepositoryService.getPrimaryRepository()
: systemRepositoryService.getRepository(requestedRepositoryId);
return RepositoryContext.centralRead(
selectedRepository.getRepositoryId(),
branch != null
? branch : selectedRepository.getDefaultBranch(),
username);
}
UserWorkspace workspace = userWorkspaceRepository.findByWorkspaceId(workspaceId)
.orElseThrow(() -> new IllegalArgumentException(
"Workspace not found while resolving repository context: " + workspaceId));
if (workspace.getUsername() != null
&& !workspace.getUsername().equals(username)
&& !"system".equals(username)) {
throw new IllegalArgumentException(
"Workspace does not belong to the active user: " + workspaceId);
}
String repositoryId = requireText(
workspace.getSourceRepositoryId(), "workspace.sourceRepositoryId");
if (!legacyRepositorySelection
&& !repositoryId.equals(requestedRepositoryId)) {
throw new IllegalArgumentException(
"Workspace repository does not match the selected repository: "
+ workspaceId);
}
String workspaceBranch = branch != null
? branch : normalizeBranch(workspace.getCurrentBranch());
return RepositoryContext.workspace(
repositoryId,
workspaceId,
workspaceBranch != null ? workspaceBranch : "draft",
username);
}
private static RepositoryContext requireContext(RepositoryContext context) {
if (context == null) {
throw new IllegalArgumentException("RepositoryContext must not be null");
}
return context;
}
private static RepositoryContext requireWritableContext(RepositoryContext context) {
RepositoryContext tenant = requireContext(context);
if (tenant.scope() == RepositoryScope.CENTRAL_READ) {
throw new IllegalStateException(
"Hypothesis mutation requires a workspace or explicit central write context");
}
return tenant;
}
private static String normalizeSessionId(String value) {
String normalized = normalizeOptional(value);
return normalized != null ? normalized : UUID.randomUUID().toString();
}
private static String normalizeUsername(String value) {
String normalized = normalizeOptional(value);
return normalized != null ? normalized : "system";
}
private static String normalizeBranch(String value) {
return normalizeOptional(value);
}
private static String normalizeOptional(String value) {
return value == null || value.isBlank() ? null : value.strip();
}
private static String requireText(String value, String field) {
String normalized = normalizeOptional(value);
if (normalized == null) {
throw new IllegalArgumentException(field + " must not be blank");
}
return normalized;
}
}