From 6f3ad45cb49eae362c66d27e6327563c2235502a Mon Sep 17 00:00:00 2001 From: fy59 Date: Thu, 17 Sep 2026 18:26:24 +0200 Subject: [PATCH] Bound generation MTP pull to current objects --- CHANGELOG.md | 3 + docs/current_state.md | 4 + docs/sync_exchange.md | 6 ++ docs/tests.md | 3 + tui/src/generation_mtp.c | 136 +++++++++++++++++++++++++++++++- tui/tests/test_generation_mtp.c | 40 +++++++--- 6 files changed, 180 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73aa50b..84121d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/current_state.md b/docs/current_state.md index b3d5bce..5a6c21e 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -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 diff --git a/docs/sync_exchange.md b/docs/sync_exchange.md index c04551e..df66af1 100644 --- a/docs/sync_exchange.md +++ b/docs/sync_exchange.md @@ -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 diff --git a/docs/tests.md b/docs/tests.md index 81a50b5..b14c026 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -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 diff --git a/tui/src/generation_mtp.c b/tui/src/generation_mtp.c index 600f02b..da6a1f7 100644 --- a/tui/src/generation_mtp.c +++ b/tui/src/generation_mtp.c @@ -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; } diff --git a/tui/tests/test_generation_mtp.c b/tui/tests/test_generation_mtp.c index 2220de3..6f0632e 100644 --- a/tui/tests/test_generation_mtp.c +++ b/tui/tests/test_generation_mtp.c @@ -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; }