Persist user JSON acceptance contracts and block unverified completion

This commit is contained in:
mateaix 2026-09-14 20:57:09 +08:00
parent 2c73da7e2a
commit 868e2dbfb1
9 changed files with 361 additions and 0 deletions

View File

@ -0,0 +1,30 @@
package vip.mate.goal.controller;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import vip.mate.common.result.R;
import vip.mate.goal.service.GoalJsonAcceptanceService;
/** Authenticated user surface, intentionally not a model tool. */
@RestController
@RequestMapping("/api/v1/goals/{goalId}/json-acceptance")
@RequiredArgsConstructor
public class GoalJsonAcceptanceController {
private final GoalJsonAcceptanceService acceptance;
@GetMapping
public R<GoalJsonAcceptanceService.View> get(@PathVariable Long goalId, Authentication auth) {
return R.ok(acceptance.get(goalId, username(auth)));
}
@PutMapping("/requirements/{criterionKey}")
public R<GoalJsonAcceptanceService.Requirement> configure(@PathVariable Long goalId, @PathVariable String criterionKey,
@RequestBody GoalJsonAcceptanceService.ConfigureRequest request, Authentication auth) {
return R.ok(acceptance.configure(goalId, criterionKey, request, username(auth)));
}
private static String username(Authentication auth) {
return auth != null && auth.isAuthenticated() ? auth.getName() : null;
}
}

View File

@ -56,6 +56,9 @@ public class GoalEntity {
/** Advances on evaluation-definition edits, independently of optimistic-lock/usage version. */
private long evaluationRevision;
/** User-selected managed JSON acceptance; never falls back to semantic completion. */
private boolean jsonAcceptanceRequired;
/** LLM-readable exit criteria; evaluator scores against this. Nullable. */
@TableField(value = "exit_criteria", updateStrategy = FieldStrategy.ALWAYS)
private String exitCriteria;

View File

@ -30,6 +30,8 @@ public class GoalResponse {
private GoalStatus status;
private boolean jsonAcceptanceRequired;
/** Opts into durable continuation; zero budgets mean unlimited only in this mode. */
private Boolean persistentExecution;

View File

@ -0,0 +1,121 @@
package vip.mate.goal.service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import vip.mate.exception.MateClawException;
import vip.mate.execution.evidence.service.JsonArtifactRecipe;
import java.util.List;
import java.util.Objects;
/** User-managed requirements. Agent tools must not expose this configuration surface. */
@Service
public class GoalJsonAcceptanceService {
private final JdbcTemplate jdbc;
private final ObjectMapper json;
public GoalJsonAcceptanceService(JdbcTemplate jdbc, ObjectMapper json) {
this.jdbc = jdbc;
this.json = json;
}
public record ConfigureRequest(Long expectedRevision, String artifactSlot, List<String> requiredFields) { }
public record Requirement(String criterionKey, String artifactSlot, long revision,
List<String> requiredFields, String configuredBy) { }
public record View(boolean required, List<Requirement> requirements) { }
private record GoalScope(long id, String conversationId, long workspaceId, String status, boolean required) { }
@Transactional
public View get(Long goalId, String username) {
GoalScope goal = authorizedGoal(goalId, username, true);
return new View(goal.required(), requirements(goalId));
}
@Transactional
public Requirement configure(Long goalId, String criterionKey, ConfigureRequest request, String username) {
GoalScope goal = authorizedGoal(goalId, username, true);
if (!List.of("active", "paused").contains(goal.status())) {
throw failure(409, "JSON requirements can only change on an active or paused goal");
}
if (request == null || request.expectedRevision() == null || request.expectedRevision() < 0) {
throw failure(400, "expectedRevision is required (0 for a new requirement)");
}
validateKey(criterionKey);
validateKey(request.artifactSlot());
List<String> fields = JsonArtifactRecipe.validate(request.requiredFields());
List<Requirement> current = requirements(goalId);
Requirement previous = current.stream().filter(r -> r.criterionKey().equals(criterionKey)).findFirst().orElse(null);
long revision = previous == null ? 0 : previous.revision();
if (request.expectedRevision() != revision) throw failure(409, "JSON requirement revision changed; reload before editing");
if (previous != null && previous.artifactSlot().equals(request.artifactSlot()) && previous.requiredFields().equals(fields)) {
return previous;
}
if (previous == null && current.size() >= 8) throw failure(400, "At most 8 JSON requirements per goal");
long next = Math.addExact(revision, 1);
String encoded = encode(fields);
if (previous == null) {
jdbc.update("""
INSERT INTO mate_goal_json_requirement
(goal_id,criterion_key,artifact_slot,revision,required_fields,created_by,updated_by,created_at,updated_at)
VALUES (?,?,?,?,?,?,?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)
""", goalId, criterionKey, request.artifactSlot(), next, encoded, username, username);
} else {
jdbc.update("""
UPDATE mate_goal_json_requirement SET artifact_slot=?,revision=?,required_fields=?,updated_by=?,updated_at=CURRENT_TIMESTAMP
WHERE goal_id=? AND criterion_key=?
""", request.artifactSlot(), next, encoded, username, goalId, criterionKey);
}
// This version write races safely with existing GoalService CAS completion.
// No removal/disable endpoint: opting in never silently restores text-only completion.
jdbc.update("UPDATE mate_agent_goal SET json_acceptance_required=TRUE,version=version+1,update_time=CURRENT_TIMESTAMP WHERE id=?", goalId);
return new Requirement(criterionKey, request.artifactSlot(), next, fields, username);
}
private GoalScope authorizedGoal(Long goalId, String username, boolean lock) {
if (username == null || username.isBlank() || "anonymous".equals(username)) throw failure(401, "Authentication required");
// Deliberately stricter than legacy system-conversation ownership fallback.
List<String> roles = jdbc.queryForList("SELECT role FROM mate_user WHERE username=? AND enabled=TRUE AND deleted=0" + (lock ? " FOR UPDATE" : ""), String.class, username);
if (roles.size() != 1) throw failure(403, "An enabled user account is required");
GoalScope initial = goal(goalId, false);
var conversations = jdbc.query("SELECT username,workspace_id FROM mate_conversation WHERE conversation_id=? AND deleted=0" + (lock ? " FOR UPDATE" : ""),
(row, i) -> Objects.equals(row.getString("username"), username) || "admin".equalsIgnoreCase(roles.getFirst())
? row.getLong("workspace_id") : null, initial.conversationId());
if (conversations.size() != 1 || !Objects.equals(conversations.getFirst(), initial.workspaceId())) throw failure(403, "Goal owner permission required");
GoalScope current = lock ? goal(goalId, true) : initial;
if (!Objects.equals(current.conversationId(), initial.conversationId()) || current.workspaceId() != initial.workspaceId()) {
throw failure(409, "Goal scope changed; reload before editing");
}
return current;
}
private GoalScope goal(Long id, boolean lock) {
var rows = jdbc.query("SELECT id,conversation_id,workspace_id,status,json_acceptance_required FROM mate_agent_goal WHERE id=? AND deleted=0" + (lock ? " FOR UPDATE" : ""),
(row, i) -> new GoalScope(row.getLong("id"), row.getString("conversation_id"), row.getLong("workspace_id"), row.getString("status"), row.getBoolean("json_acceptance_required")), id);
if (rows.size() != 1) throw failure(404, "Goal not found");
return rows.getFirst();
}
private List<Requirement> requirements(Long goalId) {
return jdbc.query("SELECT criterion_key,artifact_slot,revision,required_fields,updated_by FROM mate_goal_json_requirement WHERE goal_id=? ORDER BY criterion_key",
(row, i) -> new Requirement(row.getString("criterion_key"), row.getString("artifact_slot"), row.getLong("revision"), decode(row.getString("required_fields")), row.getString("updated_by")), goalId);
}
private static void validateKey(String key) {
if (key == null || !key.matches("[a-z][a-z0-9_-]{0,63}")) throw failure(400, "Keys must be 164 lowercase letters, digits, underscores or hyphens, starting with a letter");
}
private String encode(List<String> fields) {
try { return json.writeValueAsString(fields); }
catch (Exception e) { throw new IllegalStateException("Cannot encode JSON requirements", e); }
}
private List<String> decode(String fields) {
try { return JsonArtifactRecipe.validate(json.readValue(fields, new TypeReference<List<String>>() { })); }
catch (Exception e) { throw new IllegalStateException("Stored JSON requirements are invalid", e); }
}
private static MateClawException failure(int code, String message) { return new MateClawException(code, message); }
}

View File

@ -400,6 +400,12 @@ public class GoalServiceImpl implements GoalService {
}
return null; // idempotent
}
if (fresh.isJsonAcceptanceRequired()) {
// User-selected requirements never fall back to model text or
// the legacy explicit-completion path while bindings are absent.
throw new MateClawException("err.goal.json_acceptance_required", 409,
"Managed JSON acceptance requires verified current artifact bindings");
}
if (evaluated && result.evaluationRevision() != fresh.getEvaluationRevision()) {
throw new MateClawException("err.goal.completion_not_verified", 409,
"Automatic completion requires the current evaluation definition revision");
@ -737,6 +743,7 @@ public class GoalServiceImpl implements GoalService {
r.setExitCriteria(e.getExitCriteria());
r.setSuccessCheckPrompt(e.getSuccessCheckPrompt());
r.setStatus(e.getStatus());
r.setJsonAcceptanceRequired(e.isJsonAcceptanceRequired());
r.setPersistentExecution(Boolean.TRUE.equals(e.getPersistentExecution()));
r.setTurnBudget(e.getTurnBudget());
r.setTurnsUsed(e.getTurnsUsed());

View File

@ -0,0 +1,14 @@
-- Explicit opt-in is durable and cannot silently fall back to text completion.
ALTER TABLE mate_agent_goal ADD COLUMN json_acceptance_required BOOLEAN NOT NULL DEFAULT FALSE;
CREATE TABLE mate_goal_json_requirement (
goal_id BIGINT NOT NULL,
criterion_key VARCHAR(64) NOT NULL,
artifact_slot VARCHAR(64) NOT NULL,
revision BIGINT NOT NULL,
required_fields TEXT NOT NULL,
created_by VARCHAR(64) NOT NULL,
updated_by VARCHAR(64) NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
PRIMARY KEY (goal_id, criterion_key)
);

View File

@ -0,0 +1,14 @@
-- Explicit opt-in is durable and cannot silently fall back to text completion.
ALTER TABLE mate_agent_goal ADD COLUMN json_acceptance_required BOOLEAN NOT NULL DEFAULT FALSE;
CREATE TABLE mate_goal_json_requirement (
goal_id BIGINT NOT NULL,
criterion_key VARCHAR(64) NOT NULL,
artifact_slot VARCHAR(64) NOT NULL,
revision BIGINT NOT NULL,
required_fields TEXT NOT NULL,
created_by VARCHAR(64) NOT NULL,
updated_by VARCHAR(64) NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
PRIMARY KEY (goal_id, criterion_key)
);

View File

@ -0,0 +1,14 @@
-- Explicit opt-in is durable and cannot silently fall back to text completion.
ALTER TABLE mate_agent_goal ADD COLUMN json_acceptance_required BOOLEAN NOT NULL DEFAULT FALSE;
CREATE TABLE mate_goal_json_requirement (
goal_id BIGINT NOT NULL,
criterion_key VARCHAR(64) NOT NULL,
artifact_slot VARCHAR(64) NOT NULL,
revision BIGINT NOT NULL,
required_fields TEXT NOT NULL,
created_by VARCHAR(64) NOT NULL,
updated_by VARCHAR(64) NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
PRIMARY KEY (goal_id, criterion_key)
);

View File

@ -0,0 +1,156 @@
package vip.mate.goal;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.TestPropertySource;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import vip.mate.MateClawApplication;
import vip.mate.exception.MateClawException;
import vip.mate.goal.model.*;
import vip.mate.goal.service.GoalJsonAcceptanceService;
import vip.mate.goal.service.GoalService;
import vip.mate.memory.spi.MemoryManager;
import java.util.List;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest(classes = MateClawApplication.class, webEnvironment = SpringBootTest.WebEnvironment.NONE)
@TestPropertySource(properties = {
"spring.datasource.url=jdbc:h2:mem:json_acceptance_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1",
"spring.ai.dashscope.api-key=test-key", "spring.main.web-application-type=none",
"mateclaw.goal.enabled=false", "mateclaw.plugin.enabled=false", "mateclaw.skill.workspace.auto-init=false",
"mateclaw.skill.workspace.root=${java.io.tmpdir}/mateclaw-json-acceptance-skills-${random.uuid}"
})
class GoalJsonAcceptanceIntegrationTest {
@MockBean private MemoryManager memory;
@Autowired private GoalService goals;
@Autowired private GoalJsonAcceptanceService acceptance;
@Autowired private JdbcTemplate jdbc;
@Autowired private PlatformTransactionManager transactions;
private String alice;
private String bob;
@BeforeEach void users() {
alice = "alice-" + UUID.randomUUID();
bob = "bob-" + UUID.randomUUID();
for (String user : List.of(alice, bob)) jdbc.update("""
INSERT INTO mate_user(id,username,password,enabled,role,create_time,update_time,deleted)
VALUES (?,?,?,TRUE,'user',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,0)
""", IdWorker.getId(), user, "unused-test-password");
}
private GoalEntity goal(boolean persistent) {
String conversation = UUID.randomUUID().toString();
jdbc.update("""
INSERT INTO mate_conversation(id,conversation_id,username,workspace_id,agent_id,create_time,update_time,deleted)
VALUES (?,?,?,1,1,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,0)
""", IdWorker.getId(), conversation, alice);
GoalCreateRequest req = new GoalCreateRequest();
req.setConversationId(conversation); req.setWorkspaceId(1L); req.setAgentId(1L);
req.setTitle("A JSON report"); req.setDescription("Produce a managed JSON report"); req.setPersistentExecution(persistent);
return goals.create(req, alice);
}
private GoalJsonAcceptanceService.ConfigureRequest request(long revision, String... fields) {
return new GoalJsonAcceptanceService.ConfigureRequest(revision, "report", List.of(fields));
}
@Test void ownerCanPersistAndReviseRequirementsWithoutAcceptingAStaleEdit() {
GoalEntity goal = goal(false);
assertFalse(acceptance.get(goal.getId(), alice).required());
var first = acceptance.configure(goal.getId(), "report-fields", request(0, "summary"), alice);
assertEquals(1, first.revision());
assertTrue(goals.toResponse(goals.getById(goal.getId())).isJsonAcceptanceRequired());
assertEquals(List.of("summary"), acceptance.get(goal.getId(), alice).requirements().getFirst().requiredFields());
var second = acceptance.configure(goal.getId(), "report-fields", request(1, "summary", "sources"), alice);
assertEquals(2, second.revision());
assertThrows(MateClawException.class, () -> acceptance.configure(goal.getId(), "report-fields", request(1, "forged"), alice));
assertEquals(List.of("summary", "sources"), acceptance.get(goal.getId(), alice).requirements().getFirst().requiredFields());
assertEquals(2, acceptance.configure(goal.getId(), "report-fields", request(2, "summary", "sources"), alice).revision());
}
@ParameterizedTest
@ValueSource(booleans = {false, true})
void selectedContractBlocksBothSyntheticExplicitAndAutomaticCompletion(boolean automatic) {
GoalEntity goal = goal(false);
goals.appendCriterion(goal.getId(), "write report", alice);
var forged = new GoalEvaluationResult(1, "report saved and checked", "completed", true, "fixture", 1, 0,
List.of(new GoalChecklistVerdict.CriterionVerdict("C1", true, "I verified the JSON")), null);
goals.recordEvaluation(goal.getId(), forged, 1, 1);
acceptance.configure(goal.getId(), "report-fields", request(0, "summary"), alice);
assertThrows(MateClawException.class, () -> {
if (automatic) goals.markEvaluatedCompleted(goal.getId(), forged);
else goals.markCompleted(goal.getId(), forged);
});
assertEquals(GoalStatus.ACTIVE, goals.getById(goal.getId()).getStatus());
assertTrue(goals.listEvents(goal.getId(), 30).stream().noneMatch(e -> "completed".equals(e.getEventType())));
}
@Test void unselectedLegacyCompletionRemainsCompatible() {
GoalEntity goal = goal(false);
assertEquals(GoalStatus.COMPLETED, goals.markCompleted(goal.getId(), null).getStatus());
assertThrows(MateClawException.class, () -> acceptance.configure(goal.getId(), "late", request(0, "summary"), alice));
}
@Test void unknownDisabledAndOtherUsersCannotConfigureOrReadEvenForSystemConversations() {
GoalEntity goal = goal(false);
assertThrows(MateClawException.class, () -> acceptance.configure(goal.getId(), "r", request(0, "summary"), null));
assertThrows(MateClawException.class, () -> acceptance.configure(goal.getId(), "r", request(0, "summary"), bob));
assertThrows(MateClawException.class, () -> acceptance.get(goal.getId(), bob));
jdbc.update("UPDATE mate_user SET enabled=FALSE WHERE username=?", alice);
assertThrows(MateClawException.class, () -> acceptance.configure(goal.getId(), "r", request(0, "summary"), alice));
jdbc.update("UPDATE mate_conversation SET username='system' WHERE conversation_id=?", goal.getConversationId());
assertThrows(MateClawException.class, () -> acceptance.configure(goal.getId(), "r", request(0, "summary"), "unknown-account"));
assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM mate_goal_json_requirement WHERE goal_id=?", Integer.class, goal.getId()));
assertFalse(goals.getById(goal.getId()).isJsonAcceptanceRequired());
}
@Test void rollbackDoesNotLeaveAContractOrEnableFlag() {
GoalEntity goal = goal(false);
new TransactionTemplate(transactions).executeWithoutResult(status -> {
acceptance.configure(goal.getId(), "r", request(0, "summary"), alice);
status.setRollbackOnly();
});
assertFalse(acceptance.get(goal.getId(), alice).required());
assertTrue(acceptance.get(goal.getId(), alice).requirements().isEmpty());
}
@Test void competingUserEditsCannotBothCommitTheSameExpectedRevision() throws Exception {
GoalEntity goal = goal(false);
var start = new java.util.concurrent.CountDownLatch(1);
try (var workers = java.util.concurrent.Executors.newFixedThreadPool(2)) {
java.util.concurrent.Callable<Boolean> edit = () -> {
start.await();
try { acceptance.configure(goal.getId(), "r", request(0, "summary"), alice); return true; }
catch (MateClawException conflict) {
assertTrue(conflict.getMessage().contains("revision changed"));
return false;
}
};
var first = workers.submit(edit); var second = workers.submit(edit); start.countDown();
assertNotEquals(first.get(10, java.util.concurrent.TimeUnit.SECONDS), second.get(10, java.util.concurrent.TimeUnit.SECONDS));
}
assertEquals(1, acceptance.get(goal.getId(), alice).requirements().size());
assertEquals(1, acceptance.get(goal.getId(), alice).requirements().getFirst().revision());
}
@Test void malformedAndUnboundedContractsAreRejectedWithoutEnablingTheGoal() {
GoalEntity goal = goal(false);
assertThrows(MateClawException.class, () -> acceptance.configure(goal.getId(), "../r", request(0, "summary"), alice));
assertThrows(MateClawException.class, () -> acceptance.configure(goal.getId(), "r", request(0, "summary", "summary"), alice));
assertFalse(goals.getById(goal.getId()).isJsonAcceptanceRequired());
for (int i=0; i<8; i++) acceptance.configure(goal.getId(), "r"+i, request(0, "summary"), alice);
assertThrows(MateClawException.class, () -> acceptance.configure(goal.getId(), "overflow", request(0, "summary"), alice));
assertEquals(8, acceptance.get(goal.getId(), alice).requirements().size());
}
}