RelationDecisionProjectionService.java

package com.taxonomy.relations.service;

import com.taxonomy.dsl.ast.BlockAst;
import com.taxonomy.dsl.ast.DocumentAst;
import com.taxonomy.dsl.command.ArchitectureRelationDslTransformer.ChangeKind;
import com.taxonomy.dsl.command.ArchitectureRelationDslTransformer.RelationIdentity;
import com.taxonomy.dsl.parser.TaxDslParser;
import com.taxonomy.dsl.storage.DslGitRepository;
import com.taxonomy.dsl.storage.DslGitRepositoryFactory;
import com.taxonomy.dsl.storage.ExpectedHeadDslCommitter;
import com.taxonomy.model.RelationType;
import com.taxonomy.relations.command.ArchitectureRelationGitCommandService.CommandResult;
import com.taxonomy.relations.command.ArchitectureRelationGitCommandService.RelationCommand;
import com.taxonomy.relations.command.ArchitectureRelationGitCommandService.RemoveRelation;
import com.taxonomy.relations.command.ArchitectureRelationGitCommandService.UpsertRelation;
import com.taxonomy.workspace.service.RepositoryContext;
import com.taxonomy.workspace.service.RepositoryScope;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Objects;

/**
 * Verifies one successful Git-authoritative relation command and hands its exact
 * post-command state to the transactional projection writer.
 *
 * <p>Git and TaxDSL validation deliberately happen before the writer opens a
 * relational transaction. The command payload is not trusted as the resulting
 * state: the exact authoritative commit is re-read, partial upserts therefore
 * preserve unspecified properties, and removals become explicit tombstones.
 * User-facing reads must not use this incremental projection until a later
 * full-branch rebuild has marked it complete.</p>
 */
@Service
public class RelationDecisionProjectionService {

    private static final String RELATION_KIND = "relation";

    private final RelationDecisionProjectionWriter projectionWriter;
    private final DslGitRepositoryFactory gitRepositoryFactory;
    private final TaxDslParser parser;
    private final ExpectedHeadDslCommitter expectedHeadVerifier;

    @Autowired
    public RelationDecisionProjectionService(
            RelationDecisionProjectionWriter projectionWriter,
            DslGitRepositoryFactory gitRepositoryFactory) {
        this(
                projectionWriter,
                gitRepositoryFactory,
                new TaxDslParser(),
                new ExpectedHeadDslCommitter());
    }

    RelationDecisionProjectionService(
            RelationDecisionProjectionWriter projectionWriter,
            DslGitRepositoryFactory gitRepositoryFactory,
            TaxDslParser parser,
            ExpectedHeadDslCommitter expectedHeadVerifier) {
        this.projectionWriter = Objects.requireNonNull(
                projectionWriter, "projectionWriter");
        this.gitRepositoryFactory = Objects.requireNonNull(
                gitRepositoryFactory, "gitRepositoryFactory");
        this.parser = Objects.requireNonNull(parser, "parser");
        this.expectedHeadVerifier = Objects.requireNonNull(
                expectedHeadVerifier, "expectedHeadVerifier");
    }

    /**
     * Re-reads the exact authority token and projects only the state found in
     * that commit.
     */
    public ProjectionResult project(
            RepositoryContext context,
            CommandResult commandResult,
            RelationCommand command) {
        ValidatedCommand validated = validate(context, commandResult, command);
        AuthoritativeState state = readAuthoritativeState(
                context,
                validated,
                commandResult,
                command);
        RelationIdentity identity = command.identity();
        ProjectionRequest request = new ProjectionRequest(
                context.repositoryId(),
                context.workspaceId(),
                context.branch(),
                identity.sourceId(),
                validated.relationType(),
                identity.targetId(),
                state.relationPresent(),
                state.status(),
                state.confidence(),
                state.provenance(),
                validated.authoritativeCommitId(),
                command.metadata().causationId());
        return projectionWriter.write(request);
    }

    private ValidatedCommand validate(
            RepositoryContext context,
            CommandResult result,
            RelationCommand command) {
        Objects.requireNonNull(context, "context");
        Objects.requireNonNull(result, "commandResult");
        Objects.requireNonNull(command, "command");

        if (context.scope() == RepositoryScope.CENTRAL_READ) {
            throw new ProjectionContextMismatchException(
                    "Relation projections require CENTRAL_WRITE, WORKSPACE or FORK scope");
        }
        if (!context.repositoryId().equals(result.repositoryId())
                || !Objects.equals(context.workspaceId(), result.workspaceId())
                || !context.branch().equals(result.branch())
                || context.scope() != result.scope()) {
            throw new ProjectionContextMismatchException(
                    "Command result does not match the exact repository context");
        }
        if (!command.metadata().causationId().equals(result.causationId())) {
            throw new ProjectionContextMismatchException(
                    "Command result causation ID does not match the command");
        }

        String authoritativeCommitId = requireCommitId(
                result.authoritativeCommitId(), "authoritativeCommitId");
        boolean changed = result.changeKind() != ChangeKind.UNCHANGED;
        if (result.commitCreated() != changed) {
            throw new ProjectionContextMismatchException(
                    "Command result commit-created flag contradicts its change kind");
        }
        String previousHeadCommit = result.previousHeadCommit();
        if (previousHeadCommit != null) {
            previousHeadCommit = requireCommitId(
                    previousHeadCommit, "previousHeadCommit");
        }
        if (!changed
                && !authoritativeCommitId.equals(previousHeadCommit)) {
            throw new ProjectionContextMismatchException(
                    "No-op command result must retain its exact previous head");
        }
        if (command instanceof UpsertRelation
                && result.changeKind() == ChangeKind.REMOVED) {
            throw new ProjectionContextMismatchException(
                    "Upsert command cannot produce a removed result");
        }
        if (command instanceof RemoveRelation
                && result.changeKind() != ChangeKind.REMOVED
                && result.changeKind() != ChangeKind.UNCHANGED) {
            throw new ProjectionContextMismatchException(
                    "Remove command cannot produce an add or update result");
        }

        RelationType relationType;
        try {
            relationType = RelationType.valueOf(
                    command.identity().relationType());
        } catch (IllegalArgumentException error) {
            throw new ProjectionContextMismatchException(
                    "Unsupported projected relation type: "
                            + command.identity().relationType(),
                    error);
        }
        return new ValidatedCommand(authoritativeCommitId, relationType);
    }

    private AuthoritativeState readAuthoritativeState(
            RepositoryContext context,
            ValidatedCommand validated,
            CommandResult commandResult,
            RelationCommand command) {
        String authoritativeCommitId = validated.authoritativeCommitId();
        DslGitRepository repository = gitRepositoryFactory.resolveRepository(context);
        verifyAuthority(
                repository,
                context,
                commandResult,
                command,
                authoritativeCommitId);
        String dsl;
        try {
            dsl = repository.getDslAtCommit(authoritativeCommitId);
        } catch (IOException error) {
            throw new ProjectionSourceException(
                    "Unable to read authoritative relation commit "
                            + authoritativeCommitId,
                    error);
        }
        if (dsl == null) {
            throw new ProjectionSourceException(
                    "Authoritative relation commit is missing: "
                            + authoritativeCommitId);
        }

        DocumentAst document;
        try {
            document = parser.parse(dsl, "architecture.taxdsl");
        } catch (RuntimeException error) {
            throw new ProjectionSourceException(
                    "Authoritative relation commit contains invalid TaxDSL: "
                            + authoritativeCommitId,
                    error);
        }

        RelationIdentity identity = command.identity();
        List<BlockAst> matches = matchingRelationBlocks(document, identity);
        if (matches.size() > 1) {
            throw new ProjectionSourceException(
                    "Authoritative commit contains duplicate relation "
                            + display(identity));
        }

        if (command instanceof UpsertRelation) {
            if (matches.isEmpty()) {
                throw new ProjectionSourceException(
                        "Authoritative commit does not contain upserted relation "
                                + display(identity));
            }
            BlockAst relation = matches.getFirst();
            if (relation.getHeaderTokens().size() != 3) {
                throw new ProjectionSourceException(
                        "Authoritative relation has a malformed header: "
                                + display(identity));
            }
            return new AuthoritativeState(
                    true,
                    uniqueProperty(relation, "status", identity),
                    confidence(relation, identity),
                    uniqueProperty(relation, "provenance", identity));
        }

        if (!matches.isEmpty()) {
            throw new ProjectionSourceException(
                    "Authoritative commit still contains removed relation "
                            + display(identity));
        }
        return new AuthoritativeState(false, null, null, null);
    }

    private void verifyAuthority(
            DslGitRepository repository,
            RepositoryContext context,
            CommandResult commandResult,
            RelationCommand command,
            String authoritativeCommitId) {
        try {
            String verifiedHead = expectedHeadVerifier.verifyExpectedHead(
                    repository,
                    context.branch(),
                    authoritativeCommitId);
            if (!authoritativeCommitId.equals(verifiedHead)) {
                throw new ProjectionSourceException(
                        "Authoritative commit is not the selected branch head: "
                                + authoritativeCommitId);
            }
            if (commandResult.commitCreated()) {
                verifyCreatedCommit(
                        repository,
                        context,
                        commandResult,
                        command,
                        authoritativeCommitId);
            }
        } catch (IOException error) {
            throw new ProjectionSourceException(
                    "Unable to verify authoritative relation commit "
                            + authoritativeCommitId,
                    error);
        }
    }

    private static void verifyCreatedCommit(
            DslGitRepository repository,
            RepositoryContext context,
            CommandResult commandResult,
            RelationCommand command,
            String authoritativeCommitId) throws IOException {
        try (RevWalk walk = new RevWalk(repository.getGitRepository())) {
            RevCommit commit = walk.parseCommit(
                    ObjectId.fromString(authoritativeCommitId));
            String expectedSummary = "relation: "
                    + commandResult.changeKind().name().toLowerCase(Locale.ROOT)
                    + " " + display(command.identity());
            if (!expectedSummary.equals(commit.getShortMessage())) {
                throw new ProjectionSourceException(
                        "Authoritative commit summary does not match the relation command");
            }
            if (!context.username().equals(commit.getAuthorIdent().getName())) {
                throw new ProjectionSourceException(
                        "Authoritative commit author does not match the repository context");
            }
            String expectedCausation = "Causation-Id: "
                    + command.metadata().causationId();
            boolean causationPresent = commit.getFullMessage().lines()
                    .anyMatch(expectedCausation::equals);
            if (!causationPresent) {
                throw new ProjectionSourceException(
                        "Authoritative commit does not contain the command causation ID");
            }

            String previousHeadCommit = commandResult.previousHeadCommit();
            if (previousHeadCommit == null) {
                if (commit.getParentCount() != 0) {
                    throw new ProjectionSourceException(
                            "Initial relation command commit unexpectedly has a parent");
                }
            } else if (commit.getParentCount() != 1
                    || !previousHeadCommit.equals(commit.getParent(0).name())) {
                throw new ProjectionSourceException(
                        "Authoritative commit parent does not match the command result");
            }
        }
    }

    private static List<BlockAst> matchingRelationBlocks(
            DocumentAst document,
            RelationIdentity identity) {
        List<BlockAst> matches = new ArrayList<>();
        for (BlockAst block : document.getBlocks()) {
            List<String> tokens = block.getHeaderTokens();
            if (RELATION_KIND.equals(block.getKind())
                    && tokens.size() >= 3
                    && identity.sourceId().equals(tokens.get(0))
                    && identity.relationType().equals(tokens.get(1))
                    && identity.targetId().equals(tokens.get(2))) {
                matches.add(block);
            }
        }
        return matches;
    }

    private static String uniqueProperty(
            BlockAst relation,
            String property,
            RelationIdentity identity) {
        List<String> values = relation.propertyValues(property);
        if (values.size() > 1) {
            throw new ProjectionSourceException(
                    "Authoritative relation has duplicate " + property
                            + " properties: " + display(identity));
        }
        return values.isEmpty() ? null : normalizeOptional(values.getFirst());
    }

    private static Double confidence(
            BlockAst relation,
            RelationIdentity identity) {
        String value = uniqueProperty(relation, "confidence", identity);
        if (value == null) {
            return null;
        }
        try {
            double parsed = Double.parseDouble(value);
            if (!Double.isFinite(parsed) || parsed < 0.0 || parsed > 1.0) {
                throw new NumberFormatException("outside [0,1]");
            }
            return parsed;
        } catch (NumberFormatException error) {
            throw new ProjectionSourceException(
                    "Authoritative relation has invalid confidence: "
                            + display(identity),
                    error);
        }
    }

    private static String requireCommitId(String value, String field) {
        if (value == null) {
            throw new ProjectionContextMismatchException(
                    field + " must not be null");
        }
        try {
            return ObjectId.fromString(value).name();
        } catch (IllegalArgumentException error) {
            throw new ProjectionContextMismatchException(
                    field + " must be a full Git object ID",
                    error);
        }
    }

    private static String display(RelationIdentity identity) {
        return identity.sourceId() + " "
                + identity.relationType() + " "
                + identity.targetId();
    }

    private static String normalizeOptional(String value) {
        return value == null || value.isBlank() ? null : value.strip();
    }

    public enum ProjectionOutcome {
        CREATED,
        UPDATED,
        REPLAYED
    }

    public record ProjectionResult(
            ProjectionOutcome outcome,
            String authoritativeCommitId,
            boolean relationPresent) {
        public ProjectionResult {
            outcome = Objects.requireNonNull(outcome, "outcome");
            authoritativeCommitId = Objects.requireNonNull(
                    authoritativeCommitId, "authoritativeCommitId");
        }
    }

    record ProjectionRequest(
            String repositoryId,
            String workspaceId,
            String branch,
            String sourceCode,
            RelationType relationType,
            String targetCode,
            boolean relationPresent,
            String status,
            Double confidence,
            String provenance,
            String authoritativeCommitId,
            String causationId) {
    }

    private record ValidatedCommand(
            String authoritativeCommitId,
            RelationType relationType) {
    }

    private record AuthoritativeState(
            boolean relationPresent,
            String status,
            Double confidence,
            String provenance) {
    }

    public static final class ProjectionContextMismatchException
            extends IllegalArgumentException {
        public ProjectionContextMismatchException(String message) {
            super(message);
        }

        public ProjectionContextMismatchException(
                String message,
                Throwable cause) {
            super(message, cause);
        }
    }

    public static final class ProjectionSourceException
            extends IllegalStateException {
        public ProjectionSourceException(String message) {
            super(message);
        }

        public ProjectionSourceException(
                String message,
                Throwable cause) {
            super(message, cause);
        }
    }

    public static final class ProjectionConflictException
            extends IllegalStateException {
        public ProjectionConflictException(String message) {
            super(message);
        }
    }
}