feat: add managed goal JSON version storage and publication API

This commit is contained in:
mateaix 2026-09-14 21:21:05 +08:00
parent 971aa536a8
commit bc6882e629
10 changed files with 355 additions and 5 deletions

View File

@ -39,12 +39,23 @@ public final class JsonArtifactRecipe {
Instant.now(), false);
}
/** Shared strict parser for managed publication and diagnostic checks. */
public static com.fasterxml.jackson.databind.JsonNode parseObject(byte[] bytes) {
if (bytes == null || bytes.length > 1_048_576) throw new MateClawException(400, "JSON must be at most 1 MiB");
try {
var document = JSON.readTree(bytes);
if (document == null || !document.isObject()) throw new IllegalArgumentException();
return document;
} catch (Exception invalid) {
throw new MateClawException(400, "A strict JSON object is required");
}
}
public static Result check(byte[] bytes, List<String> requestedFields) {
List<String> fields = validate(requestedFields);
if (bytes == null || bytes.length > 1_048_576) return outcome("UNKNOWN", fields, List.of());
try {
var document = JSON.readTree(bytes);
if (document == null || !document.isObject()) return outcome("INVALID_JSON", fields, List.of());
var document = parseObject(bytes);
List<String> missing = fields.stream().filter(field -> !document.hasNonNull(field)).toList();
return outcome(missing.isEmpty() ? "MATCH" : "MISSING_FIELDS", fields, missing);
} catch (Exception invalid) {

View File

@ -5,6 +5,7 @@ import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import vip.mate.common.result.R;
import vip.mate.goal.service.GoalJsonAcceptanceService;
import vip.mate.goal.service.ManagedGoalJsonService;
/** Authenticated user surface, intentionally not a model tool. */
@RestController
@ -12,6 +13,7 @@ import vip.mate.goal.service.GoalJsonAcceptanceService;
@RequiredArgsConstructor
public class GoalJsonAcceptanceController {
private final GoalJsonAcceptanceService acceptance;
private final ManagedGoalJsonService artifacts;
@GetMapping
public R<GoalJsonAcceptanceService.View> get(@PathVariable Long goalId, Authentication auth) {
@ -24,6 +26,22 @@ public class GoalJsonAcceptanceController {
return R.ok(acceptance.configure(goalId, criterionKey, request, username(auth)));
}
@GetMapping("/artifacts")
public R<java.util.List<ManagedGoalJsonService.Slot>> artifacts(@PathVariable Long goalId, Authentication auth) {
return R.ok(artifacts.list(goalId, username(auth)));
}
@PostMapping("/artifacts/{slot}")
public R<ManagedGoalJsonService.Artifact> publish(@PathVariable Long goalId, @PathVariable String slot,
@RequestBody ManagedGoalJsonService.PublishRequest request, Authentication auth) {
return R.ok(artifacts.publish(goalId, slot, request, username(auth)));
}
@GetMapping("/artifacts/versions/{artifactId}")
public R<ManagedGoalJsonService.Content> version(@PathVariable Long goalId, @PathVariable String artifactId, Authentication auth) {
return R.ok(artifacts.read(goalId, artifactId, username(auth)));
}
private static String username(Authentication auth) {
return auth != null && auth.isAuthenticated() ? auth.getName() : null;
}

View File

@ -26,7 +26,7 @@ public class GoalJsonAcceptanceService {
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) { }
record GoalScope(long id, String conversationId, long workspaceId, String status, boolean required) { }
@Transactional
public View get(Long goalId, String username) {
@ -74,7 +74,7 @@ public class GoalJsonAcceptanceService {
return new Requirement(criterionKey, request.artifactSlot(), next, fields, username);
}
private GoalScope authorizedGoal(Long goalId, String username, boolean lock) {
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);
@ -98,7 +98,7 @@ public class GoalJsonAcceptanceService {
return rows.getFirst();
}
private List<Requirement> requirements(Long goalId) {
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);
}

View File

@ -0,0 +1,119 @@
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.exception.MateClawException;
import vip.mate.execution.evidence.service.JsonArtifactRecipe;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.sql.Timestamp;
import java.util.HexFormat;
import java.util.List;
import java.util.UUID;
/** Managed JSON is independent of mutable workspace files and cache metadata.
* Database credentials and the service host are trusted; hashes do not isolate a hostile host. */
@Service
public class ManagedGoalJsonService {
private final JdbcTemplate jdbc;
private final GoalJsonAcceptanceService acceptance;
public ManagedGoalJsonService(JdbcTemplate jdbc, GoalJsonAcceptanceService acceptance) {
this.jdbc = jdbc;
this.acceptance = acceptance;
}
public record PublishRequest(Long expectedGeneration, String jsonContent) { }
public record Artifact(String artifactId, String artifactSlot, long generation, String sha256,
int byteLength, String producerKind, Instant createdAt, Instant expiresAt) { }
public record Content(Artifact artifact, String jsonContent) { }
public record Slot(String artifactSlot, long generation, Artifact current) { }
@Transactional
public List<Slot> list(Long goalId, String username) {
acceptance.authorizedGoal(goalId, username, true);
return slots(goalId);
}
@Transactional
public Artifact publish(Long goalId, String slot, PublishRequest request, String username) {
var goal = acceptance.authorizedGoal(goalId, username, true);
return publishLocked(goal, slot, request, "user", username);
}
@Transactional
public Content read(Long goalId, String artifactId, String username) {
acceptance.authorizedGoal(goalId, username, true);
var rows = jdbc.query("SELECT * FROM mate_goal_json_artifact WHERE goal_id=? AND artifact_id=?",
(r, i) -> new Content(artifact(r), r.getString("json_body")), goalId, artifactId);
if (rows.size() != 1) throw failure(404, "Managed JSON version not found");
return rows.getFirst();
}
// Caller must hold the authorized goal lock in the same transaction.
Artifact publishLocked(GoalJsonAcceptanceService.GoalScope goal, String slot, PublishRequest request,
String producerKind, String producerId) {
if (!List.of("active", "paused").contains(goal.status())) throw failure(409, "Goal is no longer writable");
if (!goal.required() || acceptance.requirements(goal.id()).stream().noneMatch(r -> r.artifactSlot().equals(slot))) {
throw failure(409, "Only a currently required JSON slot can be published");
}
if (request == null || request.expectedGeneration() == null || request.expectedGeneration() < 0) {
throw failure(400, "expectedGeneration is required (0 for an empty slot)");
}
String content = request.jsonContent();
if (content == null || content.length() > 1_048_576) throw failure(400, "JSON must be at most 1 MiB");
byte[] bytes = content.getBytes(StandardCharsets.UTF_8);
if (!content.equals(new String(bytes, StandardCharsets.UTF_8))) throw failure(400, "JSON must be valid UTF-8");
JsonArtifactRecipe.parseObject(bytes);
var generations = jdbc.queryForList("SELECT generation FROM mate_goal_json_slot WHERE goal_id=? AND artifact_slot=?", Long.class, goal.id(), slot);
long generation = generations.isEmpty() ? 0 : generations.getFirst();
if (request.expectedGeneration() != generation) throw failure(409, "JSON slot generation changed; reload before publishing");
Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM mate_goal_json_artifact WHERE goal_id=?", Integer.class, goal.id());
if (count == null || count >= 32) throw failure(409, "Managed JSON limit reached (32 versions per goal)");
long next = Math.addExact(generation, 1);
// Whole seconds also round-trip through MySQL TIMESTAMP without fractional precision.
Instant created = Instant.now().truncatedTo(java.time.temporal.ChronoUnit.SECONDS);
Artifact artifact = new Artifact(UUID.randomUUID().toString(), slot, next, digest(bytes), bytes.length,
producerKind, created, created.plusSeconds(86_400));
jdbc.update("""
INSERT INTO mate_goal_json_artifact
(artifact_id,goal_id,artifact_slot,generation,json_body,sha256,byte_length,producer_kind,producer_id,created_at,expires_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?)
""", artifact.artifactId(), goal.id(), slot, next, content, artifact.sha256(), bytes.length,
producerKind, producerId, Timestamp.from(created), Timestamp.from(artifact.expiresAt()));
if (generation == 0) jdbc.update("INSERT INTO mate_goal_json_slot(goal_id,artifact_slot,generation,artifact_id) VALUES (?,?,?,?)",
goal.id(), slot, next, artifact.artifactId());
else jdbc.update("UPDATE mate_goal_json_slot SET generation=?,artifact_id=? WHERE goal_id=? AND artifact_slot=?",
next, artifact.artifactId(), goal.id(), slot);
jdbc.update("UPDATE mate_agent_goal SET version=version+1,update_time=CURRENT_TIMESTAMP WHERE id=?", goal.id());
return artifact;
}
List<Slot> slots(Long goalId) {
return acceptance.requirements(goalId).stream().map(GoalJsonAcceptanceService.Requirement::artifactSlot).distinct().sorted()
.map(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=?
""", (r, i) -> artifact(r), goalId, slot);
if (rows.isEmpty()) return new Slot(slot, 0, null);
var current = rows.getFirst();
return new Slot(slot, current.generation(), current);
}).toList();
}
private static Artifact artifact(java.sql.ResultSet r) throws java.sql.SQLException {
return new Artifact(r.getString("artifact_id"), r.getString("artifact_slot"), r.getLong("generation"),
r.getString("sha256"), r.getInt("byte_length"), r.getString("producer_kind"),
r.getTimestamp("created_at").toInstant(), r.getTimestamp("expires_at").toInstant());
}
static String digest(byte[] bytes) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); }
catch (java.security.NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
}
private static MateClawException failure(int code, String message) { return new MateClawException(code, message); }
}

View File

@ -0,0 +1,22 @@
-- Application-managed append-only content; slot pointers advance under the goal lock.
CREATE TABLE mate_goal_json_artifact (
artifact_id VARCHAR(36) NOT NULL PRIMARY KEY,
goal_id BIGINT NOT NULL,
artifact_slot VARCHAR(64) NOT NULL,
generation BIGINT NOT NULL,
json_body CLOB NOT NULL,
sha256 VARCHAR(64) NOT NULL,
byte_length INTEGER NOT NULL,
producer_kind VARCHAR(32) NOT NULL,
producer_id VARCHAR(128) NOT NULL,
created_at TIMESTAMP NOT NULL,
expires_at TIMESTAMP NOT NULL,
UNIQUE (goal_id, artifact_slot, generation)
);
CREATE TABLE mate_goal_json_slot (
goal_id BIGINT NOT NULL,
artifact_slot VARCHAR(64) NOT NULL,
generation BIGINT NOT NULL,
artifact_id VARCHAR(36) NOT NULL,
PRIMARY KEY (goal_id, artifact_slot)
);

View File

@ -0,0 +1,22 @@
-- Application-managed append-only content; slot pointers advance under the goal lock.
CREATE TABLE mate_goal_json_artifact (
artifact_id VARCHAR(36) NOT NULL PRIMARY KEY,
goal_id BIGINT NOT NULL,
artifact_slot VARCHAR(64) NOT NULL,
generation BIGINT NOT NULL,
json_body TEXT NOT NULL,
sha256 VARCHAR(64) NOT NULL,
byte_length INTEGER NOT NULL,
producer_kind VARCHAR(32) NOT NULL,
producer_id VARCHAR(128) NOT NULL,
created_at TIMESTAMP NOT NULL,
expires_at TIMESTAMP NOT NULL,
UNIQUE (goal_id, artifact_slot, generation)
);
CREATE TABLE mate_goal_json_slot (
goal_id BIGINT NOT NULL,
artifact_slot VARCHAR(64) NOT NULL,
generation BIGINT NOT NULL,
artifact_id VARCHAR(36) NOT NULL,
PRIMARY KEY (goal_id, artifact_slot)
);

View File

@ -0,0 +1,22 @@
-- Application-managed append-only content; slot pointers advance under the goal lock.
CREATE TABLE mate_goal_json_artifact (
artifact_id VARCHAR(36) NOT NULL PRIMARY KEY,
goal_id BIGINT NOT NULL,
artifact_slot VARCHAR(64) NOT NULL,
generation BIGINT NOT NULL,
json_body MEDIUMTEXT NOT NULL,
sha256 VARCHAR(64) NOT NULL,
byte_length INTEGER NOT NULL,
producer_kind VARCHAR(32) NOT NULL,
producer_id VARCHAR(128) NOT NULL,
created_at TIMESTAMP NOT NULL,
expires_at TIMESTAMP NOT NULL,
UNIQUE (goal_id, artifact_slot, generation)
);
CREATE TABLE mate_goal_json_slot (
goal_id BIGINT NOT NULL,
artifact_slot VARCHAR(64) NOT NULL,
generation BIGINT NOT NULL,
artifact_id VARCHAR(36) NOT NULL,
PRIMARY KEY (goal_id, artifact_slot)
);

View File

@ -0,0 +1,19 @@
# Managed JSON acceptance for Goals
Expand JSON acceptance requirements in the Goal panel. The conversation owner or an administrator explicitly saves up to eight requirements, each mapping an artifact slot to 116 top-level fields. Fields must exist and be non-null; false, zero and empty strings are allowed. This is a presence check, not a quality judgment. Opt-in is durable: requirements can be revised using their current revision, but required mode cannot be disabled. Stale revisions produce a conflict.
The current stage provides user configuration and independent managed version storage. Binding verification and the successful strong-completion path are still pending. Selected goals temporarily reject completion without falling back to textual claims. Unselected goals retain existing behavior.
## Managed version API
Prefix: `/api/v1/goals/{goalId}/json-acceptance`. An enabled account with conversation-owner or administrator permission is required. Preserve IDs, revisions and generations as strings in clients.
- `GET /`: read required mode and requirements.
- `PUT /requirements/{criterionKey}`: send `expectedRevision`, `artifactSlot` and `requiredFields`; use revision `0` for a new requirement.
- `GET /artifacts`: list required slots and current versions; an empty slot has generation `0`.
- `POST /artifacts/{slot}`: send `expectedGeneration` and `jsonContent` (a string containing the original JSON body) to append a version and atomically advance the slot.
- `GET /artifacts/versions/{artifactId}`: read metadata and exact content of a version belonging to this goal, including historical versions. Read access does not imply current acceptance eligibility.
Publication requires an active or paused goal and a slot referenced by a current requirement. Content must be a strict JSON object: duplicate keys, trailing documents, nesting beyond 32 levels and UTF-8 content over 1 MiB are rejected. Each goal can retain at most 32 versions; the limit rejects new publication instead of overwriting history. Each version expires after 24 hours. Republishing identical bytes still creates a new version. Reload after a generation conflict rather than automatically overwriting another publication.
Managed bodies live independently in the database. Ordinary workspace files, cache paths and hashes in text are not substitutes. No publication API edits historical bodies; bodies and pointers commit together. SHA-256 identifies content and supports integrity checks; it does not isolate an attacker with database credentials or host privileges. The database and service host are trusted foundations of this limited protocol. MySQL and Kingbase/PostgreSQL migrations have not yet been exercised against external database instances; H2 service integration tests do not establish that coverage.

View File

@ -0,0 +1,19 @@
# Goal 受管 JSON 验收
在 Goal 面板展开“JSON 验收要求”,由对话所有者或管理员显式保存要求。每个 Goal 最多 8 条要求,每条绑定一个产物槽和 116 个顶层字段。字段检查表示字段存在且不为 nullfalse、0 和空字符串允许,不等于内容质量判断。保存后不可关闭强验收模式,可以带当前 revision 修改要求;旧修订会返回冲突。
当前阶段已提供用户配置与独立受管版本存储;绑定检查和强验收成功完成路径尚未接通。选中此模式的 Goal 暂时拒绝完成,不会回退到文字声明;未选中的 Goal 保持既有行为。
## 受管版本接口
接口前缀 `/api/v1/goals/{goalId}/json-acceptance`需要启用账户及对话所有者或管理员权限。ID、revision 和 generation 在响应中使用字符串,客户端应原样保留。
- `GET /`:读取启用状态和要求。
- `PUT /requirements/{criterionKey}`:提交 `expectedRevision`、`artifactSlot`、`requiredFields`。新要求的 revision 为 `0`
- `GET /artifacts`:列出当前要求使用的槽及当前版本。空槽 generation 为 `0`
- `POST /artifacts/{slot}`:提交 `expectedGeneration``jsonContent`(包含原始 JSON 正文的字符串),原子追加新版本并推进槽。
- `GET /artifacts/versions/{artifactId}`:读取本 Goal 指定版本的元数据及原始正文,包括历史版本;读取历史版本不表示它仍可用于验收。
仅当前要求引用的槽可发布Goal 必须 active 或 paused。正文必须是严格 JSON 对象,拒绝重复键、尾随文档、超过 32 层的嵌套及超过 1 MiB 的 UTF-8 内容。每个 Goal 最多保存 32 个版本;达到配额拒绝继续发布,不覆盖旧版本。每版有效期 24 小时,重复发布同样正文也产生新版本。客户端遇到 generation 冲突应重新读取,不自动覆盖他人发布。
这些版本独立存储在数据库,不能用普通工作区文件、缓存路径或文字中的 hash 替代。发布接口不支持更新历史正文所有版本与槽指针同事务保存。SHA-256 用于标识及完整性核对,不能隔离拥有数据库凭据或宿主权限的攻击者;数据库和服务宿主是此有限协议的可信基础。当前未对 MySQL、Kingbase/PostgreSQL 实例执行迁移验收H2 服务集成测试不等于外部数据库验证。

View File

@ -35,6 +35,7 @@ class GoalJsonAcceptanceIntegrationTest {
@MockBean private MemoryManager memory;
@Autowired private GoalService goals;
@Autowired private GoalJsonAcceptanceService acceptance;
@Autowired private vip.mate.goal.service.ManagedGoalJsonService artifacts;
@Autowired private JdbcTemplate jdbc;
@Autowired private PlatformTransactionManager transactions;
private String alice;
@ -153,4 +154,101 @@ class GoalJsonAcceptanceIntegrationTest {
assertThrows(MateClawException.class, () -> acceptance.configure(goal.getId(), "overflow", request(0, "summary"), alice));
assertEquals(8, acceptance.get(goal.getId(), alice).requirements().size());
}
private vip.mate.goal.service.ManagedGoalJsonService.PublishRequest publication(long generation, String content) {
return new vip.mate.goal.service.ManagedGoalJsonService.PublishRequest(generation, content);
}
@Test void managedVersionsPreserveExactBytesAndRejectStaleOverwriteAndForeignReads() {
GoalEntity goal = goal(false);
acceptance.configure(goal.getId(), "r", request(0, "summary"), alice);
assertEquals(0, artifacts.list(goal.getId(), alice).getFirst().generation());
String original = "{ \"summary\": false, \"count\": 0 }";
var first = artifacts.publish(goal.getId(), "report", publication(0, original), alice);
assertEquals(1, first.generation());
assertEquals(original, artifacts.read(goal.getId(), first.artifactId(), alice).jsonContent());
try {
assertEquals(java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256")
.digest(original.getBytes(java.nio.charset.StandardCharsets.UTF_8))), first.sha256());
} catch (java.security.NoSuchAlgorithmException impossible) { throw new AssertionError(impossible); }
assertEquals(first, artifacts.read(goal.getId(), first.artifactId(), alice).artifact());
assertEquals(86_400, java.time.Duration.between(first.createdAt(), first.expiresAt()).toSeconds());
var second = artifacts.publish(goal.getId(), "report", publication(1, "{\"summary\":\"next\"}"), alice);
assertNotEquals(first.artifactId(), second.artifactId());
assertEquals(second.artifactId(), artifacts.list(goal.getId(), alice).getFirst().current().artifactId());
assertEquals(original, artifacts.read(goal.getId(), first.artifactId(), alice).jsonContent());
assertThrows(MateClawException.class, () -> artifacts.publish(goal.getId(), "report", publication(1, "{}"), alice));
assertThrows(MateClawException.class, () -> artifacts.read(goal.getId(), first.artifactId(), bob));
assertThrows(MateClawException.class, () -> artifacts.read(goal(false).getId(), first.artifactId(), alice));
}
@Test void managedPublicationRejectsInvalidObjectsAndUnrequiredSlotsWithoutCreatingVersions() {
GoalEntity goal = goal(false);
assertThrows(MateClawException.class, () -> artifacts.publish(goal.getId(), "report", publication(0, "{}"), alice));
acceptance.configure(goal.getId(), "r", request(0, "summary"), alice);
for (String invalid : List.of("[]", "null", "{\"a\":1,\"a\":2}", "{} {}", "[".repeat(33)+"]".repeat(33), "{\"a\":\""+"".repeat(350000)+"\"}")) {
assertThrows(MateClawException.class, () -> artifacts.publish(goal.getId(), "report", publication(0, invalid), alice));
}
assertThrows(MateClawException.class, () -> artifacts.publish(goal.getId(), "other", publication(0, "{}"), alice));
assertThrows(MateClawException.class, () -> artifacts.publish(goal.getId(), "report", publication(0, "{}"), bob));
assertEquals(0, artifacts.list(goal.getId(), alice).getFirst().generation());
assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM mate_goal_json_artifact WHERE goal_id=?", Integer.class, goal.getId()));
}
@Test void managedPublicationRollsBackBodyPointerAndGoalVersionTogether() {
GoalEntity goal = goal(false);
acceptance.configure(goal.getId(), "r", request(0, "summary"), alice);
var version = goals.getById(goal.getId()).getVersion();
new TransactionTemplate(transactions).executeWithoutResult(status -> {
artifacts.publish(goal.getId(), "report", publication(0, "{}"), alice);
status.setRollbackOnly();
});
assertEquals(version, goals.getById(goal.getId()).getVersion());
assertEquals(0, artifacts.list(goal.getId(), alice).getFirst().generation());
assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM mate_goal_json_artifact WHERE goal_id=?", Integer.class, goal.getId()));
}
@Test void competingPublishersHaveExactlyOneCurrentGeneration() throws Exception {
GoalEntity goal = goal(false);
acceptance.configure(goal.getId(), "r", request(0, "summary"), alice);
var start = new java.util.concurrent.CountDownLatch(1);
try (var workers = java.util.concurrent.Executors.newFixedThreadPool(2)) {
java.util.concurrent.Callable<Boolean> publish = () -> {
start.await();
try { artifacts.publish(goal.getId(), "report", publication(0, "{}"), alice); return true; }
catch (MateClawException conflict) {
assertTrue(conflict.getMessage().contains("generation changed"));
return false;
}
};
var first = workers.submit(publish); var second = workers.submit(publish); start.countDown();
assertNotEquals(first.get(10, java.util.concurrent.TimeUnit.SECONDS), second.get(10, java.util.concurrent.TimeUnit.SECONDS));
}
assertEquals(1, artifacts.list(goal.getId(), alice).getFirst().generation());
assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM mate_goal_json_artifact WHERE goal_id=?", Integer.class, goal.getId()));
}
@Test void quotaAndTerminalStateNeverReuseOrMutateOldVersions() {
GoalEntity goal = goal(false);
acceptance.configure(goal.getId(), "r", request(0, "summary"), alice);
var first = artifacts.publish(goal.getId(), "report", publication(0, "{}"), alice);
for (int i=1; i<32; i++) artifacts.publish(goal.getId(), "report", publication(i, "{}"), alice);
assertThrows(MateClawException.class, () -> artifacts.publish(goal.getId(), "report", publication(32, "{}"), alice));
assertEquals(32, artifacts.list(goal.getId(), alice).getFirst().generation());
assertEquals("{}", artifacts.read(goal.getId(), first.artifactId(), alice).jsonContent());
jdbc.update("UPDATE mate_agent_goal SET status='abandoned' WHERE id=?", goal.getId());
assertThrows(MateClawException.class, () -> artifacts.publish(goal.getId(), "report", publication(32, "{}"), alice));
}
@Test void managedJsonAboveSmallTextCapacityRemainsExactAndDisabledOwnerLosesAccess() {
GoalEntity goal = goal(false);
acceptance.configure(goal.getId(), "r", request(0, "summary"), alice);
String content = "{\"summary\":\"" + "".repeat(30_000) + "\"}";
var stored = artifacts.publish(goal.getId(), "report", publication(0, content), alice);
assertTrue(stored.byteLength() > 65_535);
assertEquals(content, artifacts.read(goal.getId(), stored.artifactId(), alice).jsonContent());
jdbc.update("UPDATE mate_user SET enabled=FALSE WHERE username=?", alice);
assertThrows(MateClawException.class, () -> artifacts.read(goal.getId(), stored.artifactId(), alice));
assertThrows(MateClawException.class, () -> artifacts.publish(goal.getId(), "report", publication(1, "{}"), alice));
}
}