Bound generation MTP pull to current objects

This commit is contained in:
fy59 2026-09-17 18:26:24 +02:00
parent f7b556cc37
commit 6f3ad45cb4
6 changed files with 180 additions and 12 deletions

View file

@ -8,6 +8,9 @@
ACK, and current referenced generation, while preserving every retained
generation, tombstone, and causal proof. Transport expiry has a stable
browser-facing code and action instead of a truncated traceback.
The reciprocal pull now reads only current coordination objects and the
exactly referenced Android generation instead of recursively mirroring
retained Android and desktop history.
- Replaced the Web Next Session placeholder with a separate read-only Core
projection that distinguishes AI proposals from active/pending execution

View file

@ -234,6 +234,10 @@ files, prior ACKs, and causal evidence are not recursively re-uploaded. Adapter
timeouts are persisted as the stable `transport_timeout` status instead of a
truncated Python traceback. The browser polls through one abortable scheduler,
orders revisions per `run_id`, and rejects stale responses from older runs.
The reciprocal MTP read is bounded to the Android peer advertisement, current
generation reference and consumption ACK, plus the single Android generation
named by that reference. Historical generations and other retained evidence
are neither traversed nor deleted during polling.
## Data semantics

View file

@ -809,6 +809,12 @@ rewrite any source object. A bounded adapter timeout is an ambiguous transport
failure with stable code `transport_timeout`; it is not evidence of rollback
and does not authorize automatic mutation replay.
MTP polling follows the same bounded ownership rule in the opposite direction.
It reads the peer advertisement, current Android generation reference and
current Android consumption ACK, then downloads only the generation directory
named by that validated reference. It does not recursively mirror retained
Android generations, desktop publications, or unrelated exchange history.
`GET /api/v1/prepared-items` is a separate read-only Web projection. It does not
change the frozen Dashboard `next_session.available` field or any exchange
format. It lists bounded durable desktop AI proposals and relevant execution

View file

@ -9,6 +9,9 @@ deployment-tool tests cover consistent SQLite backup and candidate inventory.
The incident regressions also prove that a phase-owned MTP outbox excludes
retained staging and unrelated legacy files, adapter expiry produces the stable
`transport_timeout` code, and operational failure classes remain distinct.
The C MTP regression keeps a malformed retained generation beside the current
reference and proves that pull downloads only the referenced generation while
still rejecting a truncated object inside that current generation.
`web_prepared_items` exercises the production SQLite projection and JSON
serializer with a proposal and an execution draft, then proves finalized and

View file

@ -253,6 +253,116 @@ static TrainlogStatus pull_folder(const TrainlogGenerationMtpIo *io,
return TRAINLOG_STATUS_OK;
}
static TrainlogStatus pull_named_file(const TrainlogGenerationMtpIo *io,
const Selection *selection,
uint32_t parent,
const char *name,
const char *local,
bool required) {
TrainlogMtpEntry entry;
TrainlogStatus status = one_named(io, selection, parent, name, false, &entry);
char path[1024], temporary[1060];
struct stat value;
int written;
if (status == TRAINLOG_STATUS_NOT_FOUND && !required) {
return TRAINLOG_STATUS_OK;
}
if (status != TRAINLOG_STATUS_OK || entry.size_bytes > 65536U) {
return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_INVALID_ARGUMENT : status;
}
written = snprintf(path, sizeof(path), "%s/%s", local, name);
if (written < 0 || (size_t)written >= sizeof(path)) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
written = snprintf(temporary, sizeof(temporary), "%s.part.%ld", path, (long)getpid());
if (written < 0 || (size_t)written >= sizeof(temporary)) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
(void)unlink(temporary);
status = io->receive(selection->bus, selection->device, entry.item_id, temporary);
if (status == TRAINLOG_STATUS_OK &&
(stat(temporary, &value) != 0 || value.st_size < 0 ||
(uint64_t)value.st_size != entry.size_bytes || rename(temporary, path) != 0)) {
status = TRAINLOG_STATUS_SYSTEM_ERROR;
}
if (status != TRAINLOG_STATUS_OK) {
(void)unlink(temporary);
}
return status;
}
static bool referenced_android_generation(const char *local, char *generation, size_t size) {
char path[1024], buffer[65537];
int fd, written;
ssize_t count;
yyjson_doc *document;
yyjson_val *root, *relative;
const char *value, *prefix = "android-objects/generations/";
bool valid = false;
written = snprintf(path, sizeof(path), "%s/android-generation-v1.json", local);
if (written < 0 || (size_t)written >= sizeof(path) ||
(fd = open(path, O_RDONLY | O_CLOEXEC)) < 0) {
return false;
}
count = read(fd, buffer, sizeof(buffer));
(void)close(fd);
if (count <= 0 || (size_t)count >= sizeof(buffer)) {
return false;
}
document = yyjson_read(buffer, (size_t)count, YYJSON_READ_NOFLAG);
if (document == NULL) {
return false;
}
root = yyjson_doc_get_root(document);
relative = yyjson_obj_get(root, "relative_path");
value = yyjson_get_str(relative);
if (yyjson_is_obj(root) && value != NULL && strncmp(value, prefix, strlen(prefix)) == 0) {
const char *leaf = value + strlen(prefix);
if (safe_leaf(leaf) && strlen(leaf) < size) {
(void)memcpy(generation, leaf, strlen(leaf) + 1U);
valid = true;
}
}
yyjson_doc_free(document);
return valid;
}
static TrainlogStatus pull_referenced_generation(const TrainlogGenerationMtpIo *io,
const Selection *selection,
const char *local,
const char *generation) {
TrainlogMtpEntry android_objects, generations, selected;
char path[1024];
uint64_t total = 0U;
size_t objects = 0U;
TrainlogStatus status;
status = one_named(io, selection, selection->root, "android-objects", true, &android_objects);
if (status == TRAINLOG_STATUS_OK) {
status = one_named(io, selection, android_objects.item_id, "generations", true, &generations);
}
if (status == TRAINLOG_STATUS_OK) {
status = one_named(io, selection, generations.item_id, generation, true, &selected);
}
if (status != TRAINLOG_STATUS_OK ||
snprintf(path, sizeof(path), "%s/android-objects/generations/%s", local, generation) < 0 ||
strlen(local) + strlen("/android-objects/generations/") + strlen(generation) >= sizeof(path)) {
return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_INVALID_ARGUMENT : status;
}
char parent[1024];
if (snprintf(parent, sizeof(parent), "%s/android-objects/generations", local) < 0 ||
strlen(local) + strlen("/android-objects/generations") >= sizeof(parent) ||
mkdir_private(local) != TRAINLOG_STATUS_OK) {
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
char objects_path[1024];
if (snprintf(objects_path, sizeof(objects_path), "%s/android-objects", local) < 0 ||
strlen(local) + strlen("/android-objects") >= sizeof(objects_path) ||
mkdir_private(objects_path) != TRAINLOG_STATUS_OK || mkdir_private(parent) != TRAINLOG_STATUS_OK) {
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
return pull_folder(io, selection, selected.item_id, path, 0U, &total, &objects);
}
static bool same_files(const char *left, const char *right) {
int left_fd = open(left, O_RDONLY | O_CLOEXEC);
int right_fd = open(right, O_RDONLY | O_CLOEXEC);
@ -459,9 +569,8 @@ TrainlogStatus trainlog_generation_mtp_pull(const TrainlogGenerationMtpIo *io,
char *diagnostic,
size_t diagnostic_size) {
Selection selection;
uint64_t total = 0U;
size_t objects = 0U;
TrainlogStatus status;
char generation[256];
if (io == NULL || expected == NULL || local == NULL) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
@ -470,7 +579,28 @@ TrainlogStatus trainlog_generation_mtp_pull(const TrainlogGenerationMtpIo *io,
status = select_peer(io, expected, local, &selection, diagnostic, diagnostic_size);
}
if (status == TRAINLOG_STATUS_OK) {
status = pull_folder(io, &selection, selection.root, local, 0U, &total, &objects);
/* WHY: Documents/Trainlog retains prior generations and causal evidence.
* Recursively mirroring that durable history makes foreground sync cost
* grow with retention and can exhaust the run deadline before admission.
* CONTRACT: pull only current coordination objects and the one Android
* generation named by the current reference. Missing optional objects
* remain a normal polling state for the Python conversation.
* INVARIANT: no remote object, ACK, tombstone, or retained generation is
* removed or rewritten by this bounded read. */
status = pull_named_file(
io, &selection, selection.root, "android-peer-v1.json", local, true);
}
if (status == TRAINLOG_STATUS_OK) {
status = pull_named_file(
io, &selection, selection.root, "android-generation-v1.json", local, false);
}
if (status == TRAINLOG_STATUS_OK) {
status = pull_named_file(
io, &selection, selection.root, "android-consumption-ack-v1.json", local, false);
}
if (status == TRAINLOG_STATUS_OK &&
referenced_android_generation(local, generation, sizeof(generation))) {
status = pull_referenced_generation(io, &selection, local, generation);
}
return status;
}

View file

@ -146,7 +146,7 @@ static TrainlogStatus receive(unsigned int b, unsigned int d, uint32_t id, const
if (!f) {
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
bytes = id == 9U ? 3U : o->size;
bytes = id == 12U ? 3U : o->size;
if (fwrite(o->data, 1, bytes, f) != bytes) {
fclose(f);
return TRAINLOG_STATUS_SYSTEM_ERROR;
@ -232,11 +232,20 @@ static int test_pull_and_manifest_last(void) {
char *manifest;
reset();
CHECK(mkdtemp(root) != NULL);
add(4, 2, true, "android-objects", NULL);
add(5, 4, true, "generations", NULL);
add(6, 5, true, "gen_11111111-1111-4111-8111-111111111111", NULL);
add(7, 6, false, "payload.json", "payload");
add(8, 6, false, "manifest.json", "manifest");
add(4,
2,
false,
"android-generation-v1.json",
"{\"relative_path\":\"android-objects/generations/gen_11111111-1111-4111-8111-"
"111111111111\"}");
add(5, 2, true, "android-objects", NULL);
add(6, 5, true, "generations", NULL);
add(7, 6, true, "gen_11111111-1111-4111-8111-111111111111", NULL);
add(8, 7, false, "payload.json", "payload");
add(9, 7, false, "manifest.json", "manifest");
add(10, 6, true, "gen_22222222-2222-4222-8222-222222222222", NULL);
add(11, 10, false, "retained.bin", "old");
find(11)->size = 99U;
CHECK(trainlog_generation_mtp_pull(
&io, "peer_11111111-1111-4111-8111-111111111111", root, diag, sizeof(diag)) ==
TRAINLOG_STATUS_OK);
@ -287,12 +296,25 @@ static int test_ambiguity_and_truncation(void) {
&io, "peer_11111111-1111-4111-8111-111111111111", root, diag, sizeof(diag)) ==
TRAINLOG_STATUS_NOT_FOUND);
reset();
add(9, 2, false, "bad.bin", "abc");
find(9)->size = 99U;
add(4,
2,
false,
"android-generation-v1.json",
"{\"relative_path\":\"android-objects/generations/gen_11111111-1111-4111-8111-"
"111111111111\"}");
add(5, 2, true, "android-objects", NULL);
add(6, 5, true, "generations", NULL);
add(7, 6, true, "gen_11111111-1111-4111-8111-111111111111", NULL);
add(12, 7, false, "bad.bin", "abc");
find(12)->size = 99U;
status = trainlog_generation_mtp_pull(
&io, "peer_11111111-1111-4111-8111-111111111111", root, diag, sizeof(diag));
CHECK(status != TRAINLOG_STATUS_OK);
CHECK(trainlog_test_join_path(path, sizeof(path), root, "bad.bin"));
CHECK(trainlog_test_join_path(
path,
sizeof(path),
root,
"android-objects/generations/gen_11111111-1111-4111-8111-111111111111/bad.bin"));
CHECK(access(path, F_OK) != 0);
return 0;
}