DocumentProvenanceCommandService.java

package com.taxonomy.provenance.service;

import com.taxonomy.model.LinkType;
import com.taxonomy.model.SourceType;
import com.taxonomy.provenance.config.DocumentImportLimits;
import com.taxonomy.provenance.model.RequirementSourceLink;
import com.taxonomy.provenance.model.SourceArtifact;
import com.taxonomy.provenance.model.SourceFragment;
import com.taxonomy.provenance.model.SourceVersion;
import com.taxonomy.provenance.repository.RequirementSourceLinkRepository;
import com.taxonomy.provenance.repository.SourceArtifactRepository;
import com.taxonomy.provenance.repository.SourceFragmentRepository;
import com.taxonomy.provenance.repository.SourceVersionRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
import java.util.Set;

/**
 * Atomic command boundary for document registration and provenance materialization.
 *
 * <p>Parsing and hashing happen before these commands are called. Once a command
 * enters its transaction, either the complete provenance graph is committed or
 * no part of it is retained.</p>
 */
@Service
public class DocumentProvenanceCommandService {

    private static final int MAX_CANDIDATE_TEXT_LENGTH = 2_000;
    private static final int MAX_SECTION_PATH_LENGTH = 500;

    private final SourceArtifactRepository artifactRepository;
    private final SourceVersionRepository versionRepository;
    private final SourceFragmentRepository fragmentRepository;
    private final RequirementSourceLinkRepository linkRepository;
    private final DocumentImportLimits limits;

    public DocumentProvenanceCommandService(SourceArtifactRepository artifactRepository,
                                            SourceVersionRepository versionRepository,
                                            SourceFragmentRepository fragmentRepository,
                                            RequirementSourceLinkRepository linkRepository,
                                            DocumentImportLimits limits) {
        this.artifactRepository = artifactRepository;
        this.versionRepository = versionRepository;
        this.fragmentRepository = fragmentRepository;
        this.linkRepository = linkRepository;
        this.limits = limits;
    }

    /** Create the logical artifact and its concrete version in one transaction. */
    @Transactional
    public RegistrationResult registerDocument(SourceType sourceType,
                                                String title,
                                                String mimeType,
                                                String contentHash) {
        if (sourceType == null) {
            throw new ProvenanceCommandException(
                    "SOURCE_TYPE_REQUIRED", "Source type is required");
        }
        if (title == null || title.isBlank()) {
            throw new ProvenanceCommandException(
                    "SOURCE_TITLE_REQUIRED", "Source title is required");
        }
        if (title.length() > 500) {
            throw new ProvenanceCommandException(
                    "SOURCE_TITLE_TOO_LARGE", "Source title exceeds 500 characters");
        }
        if (mimeType == null || mimeType.isBlank()) {
            throw new ProvenanceCommandException(
                    "MIME_TYPE_REQUIRED", "MIME type is required");
        }
        if (contentHash == null || contentHash.isBlank()) {
            throw new ProvenanceCommandException(
                    "CONTENT_HASH_REQUIRED", "Content hash is required");
        }

        SourceArtifact artifact = artifactRepository.save(
                new SourceArtifact(sourceType, title));
        SourceVersion version = new SourceVersion(artifact);
        version.setMimeType(mimeType);
        version.setContentHash(contentHash);
        version = versionRepository.save(version);

        return new RegistrationResult(artifact.getId(), version.getId());
    }

    /**
     * Validate and materialize all selected candidates atomically.
     *
     * <p>A pessimistic lock on the source version serializes concurrent retries
     * for the same document version. Stable content-derived identities then make
     * the operation idempotent without requiring an unsafe schema constraint to
     * be added to installations that may already contain historic duplicates.</p>
     */
    @Transactional
    public ConfirmationResult confirmCandidates(long artifactId,
                                                 long versionId,
                                                 List<CandidateInput> requestedCandidates) {
        List<CandidateInput> candidates = validateCandidates(requestedCandidates);

        SourceArtifact artifact = artifactRepository.findById(artifactId)
                .orElseThrow(() -> new ProvenanceCommandException(
                        "SOURCE_NOT_FOUND", "Source artifact was not found"));
        SourceVersion version = versionRepository.findByIdForUpdate(versionId)
                .orElseThrow(() -> new ProvenanceCommandException(
                        "SOURCE_NOT_FOUND", "Source version was not found"));

        if (version.getSourceArtifact() == null
                || !Objects.equals(version.getSourceArtifact().getId(), artifact.getId())) {
            throw new ProvenanceCommandException(
                    "SOURCE_VERSION_MISMATCH",
                    "The selected source version does not belong to the selected artifact");
        }

        int linked = 0;
        int alreadyLinked = 0;
        Set<String> identitiesInRequest = new HashSet<>();

        for (CandidateInput candidate : candidates) {
            String fragmentHash = candidateHash(candidate);
            String requirementId = "DOC-" + artifactId + "-" + versionId + "-" + fragmentHash;

            if (!identitiesInRequest.add(requirementId)
                    || linkRepository
                    .existsByRequirementIdAndSourceArtifactIdAndSourceVersionIdAndLinkType(
                            requirementId,
                            artifactId,
                            versionId,
                            LinkType.EXTRACTED_FROM)) {
                alreadyLinked++;
                continue;
            }

            SourceFragment fragment = new SourceFragment(version, candidate.text());
            fragment.setSectionPath(candidate.sectionHeading());
            fragment.setFragmentHash(fragmentHash);
            fragment = fragmentRepository.save(fragment);

            RequirementSourceLink link = new RequirementSourceLink(
                    requirementId, artifact, LinkType.EXTRACTED_FROM);
            link.setSourceVersion(version);
            link.setSourceFragment(fragment);
            linkRepository.save(link);
            linked++;
        }

        return new ConfirmationResult(linked, alreadyLinked);
    }

    private List<CandidateInput> validateCandidates(
            List<CandidateInput> requestedCandidates) {
        if (requestedCandidates == null || requestedCandidates.isEmpty()) {
            throw new ProvenanceCommandException(
                    "NO_CANDIDATES", "No candidates were selected");
        }
        if (requestedCandidates.size() > limits.getMaxCandidates()) {
            throw new DocumentLimitException(
                    "CANDIDATE_LIMIT_EXCEEDED",
                    "Candidate count exceeds the configured limit of "
                            + limits.getMaxCandidates());
        }

        List<CandidateInput> validated = new ArrayList<>(requestedCandidates.size());
        for (CandidateInput candidate : requestedCandidates) {
            if (candidate == null
                    || candidate.text() == null
                    || candidate.text().isBlank()) {
                throw new ProvenanceCommandException(
                        "CANDIDATE_TEXT_REQUIRED",
                        "Every candidate must contain text");
            }
            String text = candidate.text().strip();
            if (text.length() > MAX_CANDIDATE_TEXT_LENGTH) {
                throw new DocumentLimitException(
                        "CANDIDATE_TEXT_TOO_LARGE",
                        "A requirement candidate exceeds "
                                + MAX_CANDIDATE_TEXT_LENGTH + " characters");
            }

            String section = candidate.sectionHeading();
            if (section != null) {
                section = section.strip();
                if (section.isEmpty()) {
                    section = null;
                } else if (section.length() > MAX_SECTION_PATH_LENGTH) {
                    throw new DocumentLimitException(
                            "CANDIDATE_SECTION_TOO_LARGE",
                            "A candidate section path exceeds "
                                    + MAX_SECTION_PATH_LENGTH + " characters");
                }
            }
            validated.add(new CandidateInput(text, section));
        }
        return List.copyOf(validated);
    }

    private static String candidateHash(CandidateInput candidate) {
        try {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            String canonical = (candidate.sectionHeading() == null
                    ? ""
                    : candidate.sectionHeading())
                    + '\u0000'
                    + candidate.text();
            return HexFormat.of().formatHex(
                    digest.digest(canonical.getBytes(StandardCharsets.UTF_8)));
        } catch (NoSuchAlgorithmException impossible) {
            throw new IllegalStateException("SHA-256 is not available", impossible);
        }
    }

    public record CandidateInput(String text, String sectionHeading) {
    }

    public record RegistrationResult(long sourceArtifactId, long sourceVersionId) {
    }

    public record ConfirmationResult(int linked, int alreadyLinked) {
    }

    /** Typed validation failure converted to a bounded API response by the controller. */
    public static class ProvenanceCommandException extends RuntimeException {
        private final String code;

        public ProvenanceCommandException(String code, String message) {
            super(message);
            this.code = code;
        }

        public String getCode() {
            return code;
        }
    }
}