package vip.mate.goal.service; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import vip.mate.agent.context.ChatOrigin; import vip.mate.exception.MateClawException; import vip.mate.execution.evidence.service.JsonArtifactRecipe; import java.nio.charset.StandardCharsets; import java.sql.Timestamp; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.List; import java.util.Objects; /** Only this service creates bindings, by checking managed bytes under the same goal lock. */ @Service public class GoalJsonBindingService { private final JdbcTemplate jdbc; private final GoalJsonAcceptanceService acceptance; private final ManagedGoalJsonService artifacts; public GoalJsonBindingService(JdbcTemplate jdbc, GoalJsonAcceptanceService acceptance, ManagedGoalJsonService artifacts) { this.jdbc = jdbc; this.acceptance = acceptance; this.artifacts = artifacts; } public record CheckRequest(Long expectedRequirementRevision, String artifactId, Long expectedGeneration) { } public record Check(String criterionKey, long requirementRevision, String artifactId, long generation, String status, List missingFields, String recipeId, int recipeRevision, Instant checkedAt, Instant expiresAt, boolean acceptanceEligible) { } public record State(String criterionKey, long requirementRevision, String artifactId, Long generation, String status, boolean acceptanceEligible) { } record Stored(String artifactId, long generation, String body, String sha256, int byteLength, Instant expiresAt) { } record Binding(long requirementRevision, long evaluationRevision, String artifactId, long generation, String sha256, String recipeId, int recipeRevision, String status, Instant expiresAt) { } @Transactional public Check check(Long goalId, String key, CheckRequest request, String username) { var goal = acceptance.authorizedGoal(goalId, username, true); return checkLocked(goal, key, request); } @Transactional public Check checkForRuntime(ChatOrigin origin, String key, CheckRequest request) { var runtime = artifacts.runtimeGoal(origin); var result = checkLocked(runtime.goal(), key, request); ManagedGoalJsonService.verifyLease(runtime); return result; } @Transactional public List state(Long goalId, String username) { acceptance.authorizedGoal(goalId, username, true); return statesLocked(goalId); } @Transactional public List stateForRuntime(ChatOrigin origin) { return statesLocked(artifacts.runtimeGoal(origin).goal().id()); } private Check checkLocked(GoalJsonAcceptanceService.GoalScope goal, String key, CheckRequest request) { if (!goal.required() || !List.of("active", "paused").contains(goal.status())) throw failure("Goal does not accept JSON checks"); var requirement = acceptance.requirements(goal.id()).stream().filter(r -> r.criterionKey().equals(key)).findFirst() .orElseThrow(() -> failure("Current JSON requirement not found")); if (request == null || request.expectedRequirementRevision() == null || request.expectedGeneration() == null || request.artifactId() == null) { throw new MateClawException(400, "Expected requirement revision, artifact ID and generation are required"); } var current = current(goal.id(), requirement.artifactSlot()); if (request.expectedRequirementRevision() != requirement.revision() || current == null || !current.artifactId().equals(request.artifactId()) || request.expectedGeneration() != current.generation()) { throw failure("Requirement or artifact changed; reload before checking"); } Instant checkedAt = Instant.now().truncatedTo(ChronoUnit.SECONDS); if (!current.expiresAt().isAfter(Instant.now())) throw failure("Current JSON version has expired"); if (!intact(current)) throw failure("Managed JSON integrity check failed"); var result = JsonArtifactRecipe.check(current.body().getBytes(StandardCharsets.UTF_8), requirement.requiredFields()); long evaluationRevision = evaluationRevision(goal.id()); // Goal serialization makes replacement safe across all supported database dialects. jdbc.update("DELETE FROM mate_goal_json_binding WHERE goal_id=? AND criterion_key=?", goal.id(), key); jdbc.update(""" INSERT INTO mate_goal_json_binding (goal_id,criterion_key,requirement_revision,evaluation_revision,artifact_id,generation,sha256, recipe_id,recipe_revision,check_status,checked_at,expires_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) """, goal.id(), key, requirement.revision(), evaluationRevision, current.artifactId(), current.generation(), current.sha256(), result.recipeId(), result.recipeRevision(), result.status(), Timestamp.from(checkedAt), Timestamp.from(current.expiresAt())); jdbc.update("UPDATE mate_agent_goal SET version=version+1,update_time=CURRENT_TIMESTAMP WHERE id=?", goal.id()); return new Check(key, requirement.revision(), current.artifactId(), current.generation(), result.status(), result.missingFields(), result.recipeId(), result.recipeRevision(), checkedAt, current.expiresAt(), "MATCH".equals(result.status())); } List statesLocked(Long goalId) { long revision = evaluationRevision(goalId); return acceptance.requirements(goalId).stream().map(r -> { Stored current = current(goalId, r.artifactSlot()); Binding binding = binding(goalId, r.criterionKey()); String status; if (current == null) status = "NO_ARTIFACT"; else if (!current.expiresAt().isAfter(Instant.now())) status = "EXPIRED"; else if (!intact(current)) status = "CORRUPT"; else if (binding == null) status = "UNBOUND"; else if (binding.requirementRevision() != r.revision()) status = "REQUIREMENT_CHANGED"; else if (binding.evaluationRevision() != revision) status = "GOAL_CHANGED"; else if (!Objects.equals(binding.artifactId(), current.artifactId()) || binding.generation() != current.generation() || !Objects.equals(binding.sha256(), current.sha256())) status = "SUPERSEDED"; else if (!"json-required-fields".equals(binding.recipeId()) || binding.recipeRevision() != 1) status = "RECIPE_CHANGED"; else if (!binding.expiresAt().equals(current.expiresAt()) || !binding.expiresAt().isAfter(Instant.now())) status = "EXPIRED"; else if (!"MATCH".equals(binding.status())) status = binding.status(); else status = JsonArtifactRecipe.check(current.body().getBytes(StandardCharsets.UTF_8), r.requiredFields()).status(); return new State(r.criterionKey(), r.revision(), current == null ? null : current.artifactId(), current == null ? null : current.generation(), status, "MATCH".equals(status)); }).toList(); } private long evaluationRevision(Long goalId) { Long revision = jdbc.queryForObject("SELECT evaluation_revision FROM mate_agent_goal WHERE id=? AND deleted=0 FOR UPDATE", Long.class, goalId); if (revision == null) throw failure("Goal definition unavailable"); return revision; } private Stored current(Long goalId, String slot) { var rows = jdbc.query(""" SELECT a.* FROM mate_goal_json_slot s JOIN mate_goal_json_artifact a ON a.artifact_id=s.artifact_id AND a.goal_id=s.goal_id AND a.artifact_slot=s.artifact_slot AND a.generation=s.generation WHERE s.goal_id=? AND s.artifact_slot=? FOR UPDATE """, (r, i) -> new Stored(r.getString("artifact_id"), r.getLong("generation"), r.getString("json_body"), r.getString("sha256"), r.getInt("byte_length"), r.getTimestamp("expires_at").toInstant()), goalId, slot); return rows.size() == 1 ? rows.getFirst() : null; } private Binding binding(Long goalId, String key) { var rows = jdbc.query("SELECT * FROM mate_goal_json_binding WHERE goal_id=? AND criterion_key=? FOR UPDATE", (r, i) -> new Binding(r.getLong("requirement_revision"), r.getLong("evaluation_revision"), r.getString("artifact_id"), r.getLong("generation"), r.getString("sha256"), r.getString("recipe_id"), r.getInt("recipe_revision"), r.getString("check_status"), r.getTimestamp("expires_at").toInstant()), goalId, key); return rows.size() == 1 ? rows.getFirst() : null; } private static boolean intact(Stored current) { byte[] bytes = current.body().getBytes(StandardCharsets.UTF_8); return bytes.length <= 1_048_576 && bytes.length == current.byteLength() && ManagedGoalJsonService.digest(bytes).equals(current.sha256()); } private static MateClawException failure(String message) { return new MateClawException(409, message); } }