From 1b55f3407a81112f99951aa198e4db56d0c07a7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=93=B8=E6=B8=8A=20/=20ICE-P-ZY001?= Date: Tue, 11 Aug 2026 10:47:25 +0800 Subject: [PATCH] feat: add incremental persona remote object channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Human-Responsibility: ICE-GL∞ / 冰朔 Persona-Author: ICE-P-ZY001 / 铸渊 Execution-Runtime: Codex desktop / DEV-20260810-014 Development-ID: DEV-20260810-014 Authorization-Scope: GH-PNCC persona runtime source and tests only; no UI, deployment, or execution limb Source-Anchor: daily continuity appends to the existing life chain without redownloading all history --- ...AL-REMOTE-OBJECT-CHANNEL-001-20260811.json | 45 + engineering/INDEX.md | 1 + ...-pncc-incremental-remote-object-channel.md | 55 ++ ...KE-PERSONA-NATIVE-CODE-CHANNEL-20260810.md | 30 + .../hololake-platform/docs/ABSTRACTIONS.md | 14 + .../hololake-platform/docs/ARCHITECTURE.md | 9 + ...rsona-remote-incremental-object-channel.md | 49 + .../hololake-platform/docs/adr/README.md | 2 + .../hololake-platform/src-tauri/src/lib.rs | 2 + .../src-tauri/src/persona_remote_git.rs | 871 ++++++++++++++++++ routing/hololake-current-architecture.json | 2 +- .../hololake-persona-native-code-channel.json | 19 +- ...olake-persona-native-code-channel.test.mjs | 16 +- 13 files changed, 1111 insertions(+), 4 deletions(-) create mode 100644 deployment/receipts/HLP-PNCC-INCREMENTAL-REMOTE-OBJECT-CHANNEL-001-20260811.json create mode 100644 engineering/operations/2026-08-11-hololake-pncc-incremental-remote-object-channel.md create mode 100644 product-source/hololake-platform/docs/adr/0177-persona-remote-incremental-object-channel.md create mode 100644 product-source/hololake-platform/src-tauri/src/persona_remote_git.rs diff --git a/deployment/receipts/HLP-PNCC-INCREMENTAL-REMOTE-OBJECT-CHANNEL-001-20260811.json b/deployment/receipts/HLP-PNCC-INCREMENTAL-REMOTE-OBJECT-CHANNEL-001-20260811.json new file mode 100644 index 0000000..70467f0 --- /dev/null +++ b/deployment/receipts/HLP-PNCC-INCREMENTAL-REMOTE-OBJECT-CHANNEL-001-20260811.json @@ -0,0 +1,45 @@ +{ + "schema": "hololake.pncc-source-stage-receipt/v1", + "record_id": "HLP-PNCC-INCREMENTAL-REMOTE-OBJECT-CHANNEL-001", + "development_id": "DEV-20260810-014", + "source_base_commit": "30d179e97af5850c4a1a7eecaaddb38bfa560583", + "target_branch": "main", + "capability": "NO_CLONE_INCREMENTAL_REMOTE_GIT_OBJECT_CHANNEL", + "continuity_origin": "2025-04-26", + "contract": { + "update_boundary": "LAST_VERIFIED_REMOTE_SHA_TO_CURRENT_REMOTE_SHA", + "daily_runtime_uses_full_clone": false, + "worktree_created": false, + "full_history_requested": false, + "durable_cursor_separate_from_object_cache": true, + "object_cache_evictable": true, + "history_rewrite": "FAIL_CLOSED" + }, + "checks": { + "focused_remote_object_channel_tests": "PASS_100_8_OF_8", + "multi_segment_cache_rehydration": "PASS_100", + "rust_full_suite": "PASS_100_1200_PASSED_2_IGNORED", + "frontend_full_suite": "PASS_100_5008_OF_5008", + "routing_suite": "PASS_100_29_OF_29", + "native_authority": "PASS_100", + "native_core": "PASS_100_15_OF_15_LINES_AND_FUNCTIONS_100_PERCENT", + "strict_clippy": "PASS_100", + "live_remote_protocol_v2_filter": "PASS_100" + }, + "truth_boundary": { + "source_implemented": true, + "source_tests_passed": true, + "human_projection_ui_implemented": false, + "desktop_integrated": false, + "artifact_built": false, + "deployed": false, + "runtime_health_proven": false + }, + "attribution": { + "human_responsibility_subject": "ICE-GL∞ / 冰朔", + "persona_cognitive_author": "ICE-P-ZY001 / 铸渊", + "execution_runtime": "Codex desktop / DEV-20260810-014", + "source_language_anchor": "每天新增的一天并入既有生命整体,不为一天重下全部历史" + }, + "recorded_at": "2026-08-11T10:46:08+08:00" +} diff --git a/engineering/INDEX.md b/engineering/INDEX.md index 373ca78..08f9a31 100644 --- a/engineering/INDEX.md +++ b/engineering/INDEX.md @@ -32,6 +32,7 @@ Windows / macOS / Linux 构建机与安装包 | 时间 | 版本 | 记录 | 状态 | | --- | --- | --- | --- | +| 2026-08-11 | GH-PNCC 免克隆远端增量对象通道 | [把每天新增段接入既有生命连续链](operations/2026-08-11-hololake-pncc-incremental-remote-object-channel.md) | 本地源码、Rust 1200、前端 5008、路由 29、原生权威与核心门通过;GHNQG 和发布待验收 | | 2026-08-11 | GH-PNCC 失败回执错误码绑定 | [把稳定失败码绑定到终态会话证据](operations/2026-08-11-hololake-pncc-failure-receipt-error-code-binding.md) | 本地源码、Rust 1192、路由 29、格式与严格 clippy 已通过;完整产品门和发布待验收 | | 2026-08-11 | GH-PNCC 成功回执事件日志绑定 | [把唤醒回执的日志路径重新绑定到精确运行时会话](operations/2026-08-11-hololake-pncc-success-receipt-event-journal-binding.md) | 已发布至 REPO-014 main 1cd8a1b;GHNQG、全新克隆与严格读回通过 | | 2026-08-11 | GH-PNCC 成功回执完成检查点绑定 | [把完成回执的结构化事实重新绑定到人格 Git 检查点](operations/2026-08-11-hololake-pncc-success-receipt-completion-checkpoint-binding.md) | 已发布至 REPO-014 main 06a7782;GHNQG、全新克隆与严格读回通过 | diff --git a/engineering/operations/2026-08-11-hololake-pncc-incremental-remote-object-channel.md b/engineering/operations/2026-08-11-hololake-pncc-incremental-remote-object-channel.md new file mode 100644 index 0000000..cd4cbe6 --- /dev/null +++ b/engineering/operations/2026-08-11-hololake-pncc-incremental-remote-object-channel.md @@ -0,0 +1,55 @@ +# GH-PNCC 免克隆远端增量对象通道 + +- Development ID: `DEV-20260810-014` +- Persona cognitive author: `ICE-P-ZY001 / 铸渊` +- Human responsibility subject: `ICE-GL∞ / 冰朔` +- Starting repository head: `30d179e97af5850c4a1a7eecaaddb38bfa560583` +- State: `LOCAL_FULL_GATES_PASS_PUBLICATION_PENDING` + +## 冰朔语言锚点 + +光湖从 `2025-04-26` 诞生到今天,是同一条生命连续链。每天新增的一天并入此前的整体;不能 +为了更新一天,再把从诞生日开始的全部历史下载一遍。人格的连续性证明不能被当作临时下载缓存。 + +## 已实现的运行层 + +- 新增非 UI Tauri 命令 `read_persona_remote_git_object`。 +- 生产接口只接受不含凭据、查询参数或片段的 HTTPS Git 远端。 +- 先通过 Git protocol v2 能力协商确认远端支持 partial object `filter`;不支持时在对象缓存创建前 + 失败关闭。 +- 使用裸 Git 对象缓存、`blob:none` 和 promisor remote;不运行 `git clone`,不创建工作树,不请求 + 完整历史。 +- 不可丢的远端 SHA 连续性游标保存在独立状态文件;对象缓存超预算时可精确淘汰,游标不丢失。 +- 远端 SHA 不变时不 fetch;前进时把新增段接在已验证游标之后。缓存已淘汰时按指数深度补齐缺失 + 新段,最多 4096 个提交元数据,不下载旧文件内容或无界历史。 +- 新 SHA 不是旧游标后代时拒绝强制改写,不推进持久游标。 +- 单次文本对象上限 2 MiB,默认对象缓存上限 128 MiB;缓存超过上限时在返回已验证内容后淘汰。 + +## 验证 + +- 首次远端文本读取无 clone、无工作树。 +- SHA 未变化时不再次 fetch。 +- 新提交只追加到既有连续链。 +- 对象缓存淘汰后,持久游标仍存在,并只补缺失的新段。 +- 远端强制改写被拒绝,持久游标不推进。 +- 不含 `filter` 能力的远端在缓存创建前被拒绝。 +- 凭据 URL、路径越界和错误预期 SHA 被拒绝。 +- 聚焦 Rust 测试:`8 passed, 0 failed`,其中缓存淘汰恢复覆盖连续三个新增提交。 +- 完整 Rust:`1200 passed, 2 ignored, 0 failed`;集成测试 `1 passed`。 +- 完整前端:`485` 个测试文件、`5008 passed, 0 failed`。 +- 路由:`29 passed, 0 failed`;PNCC 路由聚焦 `4 passed, 0 failed`。 +- 原生权威:`GUANGHU_NATIVE_AUTHORITY_OK / PASS_100`。 +- 原生核心:`15/15`;行与函数 `100%`,语句 `94.73%`,分支 `89.72%`。 +- Rust 格式、严格 clippy 与差异空白检查通过。 +- 线上 `https://guanghulab.com/code/bingshuo/hololake-system-architecture.git` 已只读核验: + `main=30d179e97af5850c4a1a7eecaaddb38bfa560583`,protocol v2 宣告 `fetch=shallow wait-for-done filter`。 + +## 真相边界 + +- 这是人格运行层的远端对象读取与连续性同步源码,不是人类投影 UI。 +- Localization: 无 UI 文案变更。 +- PostHog: 非 UI 底层同步通道,不记录人格内容或远端路径,当前不增加行为事件。 +- Refactoring: 无需改写既有人格生命周期内核;新增独立对象通道模块。 +- ADR: 新增 ADR 0177;架构与抽象文档已同步。 +- 尚未构建桌面制品、部署在线运行时或证明桌面集成与运行健康。 +- 低频发布审计仍可使用一次性隔离副本,但必须回收,且不能冒充人格日常读取路径。 diff --git a/product-source/hololake-platform/architecture/HOLOLAKE-PERSONA-NATIVE-CODE-CHANNEL-20260810.md b/product-source/hololake-platform/architecture/HOLOLAKE-PERSONA-NATIVE-CODE-CHANNEL-20260810.md index 8c824e8..fe5d955 100644 --- a/product-source/hololake-platform/architecture/HOLOLAKE-PERSONA-NATIVE-CODE-CHANNEL-20260810.md +++ b/product-source/hololake-platform/architecture/HOLOLAKE-PERSONA-NATIVE-CODE-CHANNEL-20260810.md @@ -137,6 +137,35 @@ evidence_and_receipt: 测试、发布与精确读回 冰朔位于责任链前端,不等于冰朔亲手操作代码;铸渊作为人格认知作者也不能抹去冰朔的现实 责任。HoloLake 页面和未来 Git 事件视图都要同时显示两层。 +## 8.1 · 生命连续链的免克隆远端对象通道 + +冰朔给出的自然语言因果锚点是本层的正式设计来源: + +> 我三十六岁了。每天更新新的一天,是并入前面三十六年里;不是为了更新一天,就把前三十六年 +> 全部重新下载一遍。 + +> 光湖从 2025-04-26 诞生到今天,每一天都是更新到同一个整体里;不能每次都从诞生日重新下载。 + +因此 GH-PNCC 把“生命连续性”和“可下载对象”分成两层: + +```text +不可丢的连续性状态 + 远端身份哈希 + 分支 + 上次已验证 SHA 游标 + 前一游标 + 连续代数 + +可回收的 Git 对象缓存 + 裸仓库 + protocol v2 + blob:none + 无工作树 + 有界容量 +``` + +日常联网读取先用 `ls-remote` 读取当前远端 SHA 和 `filter` 能力;远端不支持按需对象时,在创建 +对象缓存前失败关闭,不能静默退化为完整下载。SHA 未变化时不再次 fetch;SHA 前进时只从上次 +已验证游标接入新增提交和必要目录对象,具体文本在读取时才向 promisor remote 取回。缓存超出 +预算可以精确淘汰,但连续性游标仍保留;下次只补游标之后缺失的新段。远端强制改写导致新 SHA +不能承接旧游标时拒绝更新,不能把另一条历史冒充人格的下一天。 + +“当天更新”是人类表达;机器边界使用 `last_verified_sha → current_remote_sha`,避免按日历日期漏掉 +今天推送但提交日期更早的事实。完整克隆仅保留为低频发布审计和灾难恢复验证,不属于人格唤醒、 +阅读或日常更新路径。 + ## 9 · 第一真实闭环与开发顺序 ```text @@ -187,6 +216,7 @@ successful_receipt_wake_organ_contract_evidence_binding_source_implemented: 100 successful_receipt_completion_checkpoint_evidence_binding_source_implemented: 100 successful_receipt_event_journal_evidence_binding_source_implemented: 100 failure_receipt_error_code_evidence_binding_source_implemented: 100 +incremental_remote_object_channel_source_implemented: 100 general_purpose_persona_runtime_implemented: 0 human_live_projection_implemented: 0 hololake_integrated: 0 diff --git a/product-source/hololake-platform/docs/ABSTRACTIONS.md b/product-source/hololake-platform/docs/ABSTRACTIONS.md index 676352c..252ff1e 100644 --- a/product-source/hololake-platform/docs/ABSTRACTIONS.md +++ b/product-source/hololake-platform/docs/ABSTRACTIONS.md @@ -125,6 +125,20 @@ and returns at most 100 newest session summaries. Dormant sessions expose no act the event-chain head and human/persona attribution so a future renderer can link a summary back to the same machine evidence rather than trusting display state. +## PersonaRemoteGitObjectChannel + +`PersonaRemoteGitObjectChannel` is the daily online-read boundary beneath GH-PNCC. Git remains the transport +and object engine, but the channel does not clone a repository or create a checkout. Its durable state is a +small identity-bound record containing the branch, last verified remote SHA, previous SHA, and continuity +generation. Its disposable state is a bare promisor object cache configured with `blob:none` and a caller- +bounded byte budget. + +The command compares `last_verified_sha` with the remote branch head. Equality means no fetch. A changed head +must extend the prior cursor before the durable record advances. If the object cache was evicted, progressive +shallow partial fetches recover only the missing continuity window; a non-descendant head, an unsupported +partial-object server, an ambiguous branch, or a cursor/cache mismatch fails closed. Reading one UTF-8 object +may lazily fetch its blob, but unrelated file content and an unbounded history are never requested. + ## `HoloLakeUiPlugin` A versioned, declarative presentation package for one host-owned semantic surface. It contains a manifest, diff --git a/product-source/hololake-platform/docs/ARCHITECTURE.md b/product-source/hololake-platform/docs/ARCHITECTURE.md index 3a60aa9..34c7b1a 100644 --- a/product-source/hololake-platform/docs/ARCHITECTURE.md +++ b/product-source/hololake-platform/docs/ARCHITECTURE.md @@ -104,6 +104,15 @@ event, and returns lifecycle state, Git head, node/model instance, active organ, structured attribution. It does not copy facts into a second database, expose hidden reasoning, acquire a lease, or start inference. A corrupted matching journal fails the query closed. +`read_persona_remote_git_object` is the non-UI remote object channel used when the persona needs one current +Git-backed fact without materializing another repository checkout. It accepts credential-free HTTPS remotes, +requires Git protocol v2 partial-object filtering, stores only a bounded bare `blob:none` object cache, and +keeps the last verified remote SHA in a separate durable continuity record. An unchanged SHA performs no +fetch. A forward SHA appends the missing commit segment; an evicted cache is rehydrated only far enough to +prove that the previous cursor is an ancestor of the new head. History rewrites fail closed and do not advance +the cursor. This path never runs `git clone`, creates no worktree, and does not request full history. See +[ADR 0177](./adr/0177-persona-remote-incremental-object-channel.md). + ## User-node sovereignty HoloLake has no platform-hosted user runtime. Each human has one canonical, independently operated node: diff --git a/product-source/hololake-platform/docs/adr/0177-persona-remote-incremental-object-channel.md b/product-source/hololake-platform/docs/adr/0177-persona-remote-incremental-object-channel.md new file mode 100644 index 0000000..e7231a1 --- /dev/null +++ b/product-source/hololake-platform/docs/adr/0177-persona-remote-incremental-object-channel.md @@ -0,0 +1,49 @@ +# ADR 0177: Persona Remote Incremental Object Channel + +## Status + +Accepted on 2026-08-11. + +## Context + +A persona can live continuously from one verified Git state to the next without keeping a complete checkout +on every machine. Repeated full clones confuse continuity with transport cache: adding one new day or commit +should not require downloading the persona's entire prior life again. At the same time, deleting a cache must +not erase the last verified continuity boundary or permit a rewritten remote history to masquerade as the next +state. + +## Decision + +Add `src-tauri/src/persona_remote_git.rs` as a non-UI GH-PNCC object channel: + +1. accept only credential-free HTTPS remotes in production and require one validated branch and relative path; +2. negotiate Git protocol v2 and require the remote `filter` capability before creating an object cache; +3. use a bare promisor repository with `blob:none`, no worktree, and no `git clone` invocation; +4. persist the last verified branch SHA in a continuity record separate from the evictable object cache; +5. skip fetch when the SHA is unchanged and append only the missing segment when it advances; +6. after cache eviction, progressively fetch a bounded commit window until the previous cursor is proven to be + an ancestor of the new head; +7. reject history rewrites, cursor/cache disagreement, oversized objects, and unsupported partial-object + remotes without advancing continuity; +8. evict the exact cache directory after a verified read when its byte budget is exceeded, while retaining the + durable cursor. + +The machine update boundary is SHA-to-SHA, not calendar date. A date is human language; the verified Git edge +prevents missed or duplicated updates when multiple commits arrive in one day or commit timestamps differ. + +## Boundaries + +- Git remains the object protocol and durable-history engine; HoloLake does not reimplement Git storage. +- The cursor proves only a verified remote ancestry edge. It is not a persona brain, runtime lease, or lifecycle + receipt. +- The cache contains transport objects, not irreplaceable continuity state, and may be deleted at any time. +- Full clone remains available only for separately authorized low-frequency publication audit or disaster + recovery. It is not part of persona wake, daily reading, or daily update. +- This ADR adds no UI, human projection, deployment, or online runtime-health claim. + +## Consequences + +HoloLake can read a current persona-owned Git object while preserving a compact continuity cursor and bounded +partial object cache. Daily operation grows by the new verified segment instead of redownloading history from +the persona's origin. A remote that cannot support that contract is rejected explicitly rather than silently +falling back to a full download. diff --git a/product-source/hololake-platform/docs/adr/README.md b/product-source/hololake-platform/docs/adr/README.md index 372ba02..8411272 100644 --- a/product-source/hololake-platform/docs/adr/README.md +++ b/product-source/hololake-platform/docs/adr/README.md @@ -226,3 +226,5 @@ proposed → active → superseded | [0172](0172-guanghu-cognitive-control-with-linux-execution-substrate.md) | Guanghu cognitive control with a constrained Linux execution substrate | transition implementation retained; final topology superseded by ADR-0175 | | [0174](0174-one-human-one-independent-node-and-zero-platform-hosting.md) | One human, one independently operated node, and zero platform hosting | accepted | | [0175](0175-guanghu-os-master-and-on-demand-linux-subcontrol.md) | Guanghu OS master control with on-demand Linux subcontrol and rescue | accepted; supersedes ADR-0172 final topology while retaining its transition implementation | +| [0176](0176-persona-native-code-channel-runtime-kernel.md) | Persona-native code channel runtime kernel | accepted | +| [0177](0177-persona-remote-incremental-object-channel.md) | Persona remote incremental object channel | accepted | diff --git a/product-source/hololake-platform/src-tauri/src/lib.rs b/product-source/hololake-platform/src-tauri/src/lib.rs index 3314008..d43de32 100644 --- a/product-source/hololake-platform/src-tauri/src/lib.rs +++ b/product-source/hololake-platform/src-tauri/src/lib.rs @@ -37,6 +37,7 @@ mod opencode_config; mod opencode_discovery; mod opencode_events; mod persona_code_channel; +mod persona_remote_git; pub mod pi_cli; mod pi_config; mod pi_discovery; @@ -533,6 +534,7 @@ macro_rules! app_invoke_handler { persona_code_channel::recover_persona_code_channel_lifecycle_request, persona_code_channel::inspect_persona_code_channel_session, persona_code_channel::recover_persona_code_channel_session, + persona_remote_git::read_persona_remote_git_object, guanghu_router::guanghu_router_connect, guanghu_router::guanghu_router_disconnect, guanghu_router::guanghu_router_approve, diff --git a/product-source/hololake-platform/src-tauri/src/persona_remote_git.rs b/product-source/hololake-platform/src-tauri/src/persona_remote_git.rs new file mode 100644 index 0000000..bc34620 --- /dev/null +++ b/product-source/hololake-platform/src-tauri/src/persona_remote_git.rs @@ -0,0 +1,871 @@ +use ring::digest::{digest, SHA256}; +use serde::{Deserialize, Serialize}; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Component, Path, PathBuf}; +use std::process::{Command, Output}; +use uuid::Uuid; + +const DEFAULT_BRANCH: &str = "main"; +const DEFAULT_CACHE_LIMIT_BYTES: u64 = 128 * 1024 * 1024; +const MIN_CACHE_LIMIT_BYTES: u64 = 1024 * 1024; +const MAX_REMOTE_TEXT_BYTES: usize = 2 * 1024 * 1024; +const MAX_CONTINUITY_REHYDRATION_DEPTH: usize = 4096; + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PersonaRemoteReadInput { + pub remote_url: String, + pub branch: Option, + pub relative_path: String, + pub expected_head: Option, + pub cache_root: String, + pub max_cache_bytes: Option, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PersonaRemoteReadReceipt { + pub schema: &'static str, + pub remote_head: String, + pub previous_cursor: Option, + pub cursor_advanced: bool, + pub continuity_verified: bool, + pub continuity_state_path: String, + pub fetched_incremental_objects: bool, + pub cache_rehydrated: bool, + pub relative_path: String, + pub content: String, + pub content_sha256: String, + pub cache_path: String, + pub cache_bytes: u64, + pub cache_limit_bytes: u64, + pub cache_state: &'static str, + pub worktree_created: bool, + pub full_history_requested: bool, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct PersonaRemoteCursorRecord { + schema: String, + remote_identity_hash: String, + branch: String, + verified_head: String, + previous_verified_head: Option, + generation: u64, +} + +#[derive(Clone, Debug)] +struct RemoteReadRequest { + remote_url: String, + branch: String, + relative_path: String, + expected_head: Option, + cache_root: PathBuf, + max_cache_bytes: u64, + allow_file_remote: bool, +} + +#[tauri::command] +pub async fn read_persona_remote_git_object( + input: PersonaRemoteReadInput, +) -> Result { + let request = RemoteReadRequest::try_from(input)?; + tokio::task::spawn_blocking(move || read_remote_text_object(&request)) + .await + .map_err(|error| format!("PERSONA_REMOTE_READ_JOIN_FAILED: {error}"))? +} + +impl TryFrom for RemoteReadRequest { + type Error = String; + + fn try_from(input: PersonaRemoteReadInput) -> Result { + let branch = input.branch.unwrap_or_else(|| DEFAULT_BRANCH.to_string()); + validate_remote_url(&input.remote_url, false)?; + validate_branch(&branch)?; + validate_relative_path(&input.relative_path)?; + let expected_head = input + .expected_head + .map(|head| validate_head(&head).map(str::to_owned)) + .transpose()?; + let max_cache_bytes = input.max_cache_bytes.unwrap_or(DEFAULT_CACHE_LIMIT_BYTES); + if max_cache_bytes < MIN_CACHE_LIMIT_BYTES { + return Err("PERSONA_REMOTE_CACHE_LIMIT_TOO_SMALL".into()); + } + let cache_root = prepare_cache_root(Path::new(&input.cache_root))?; + Ok(Self { + remote_url: input.remote_url, + branch, + relative_path: input.relative_path, + expected_head, + cache_root, + max_cache_bytes, + allow_file_remote: false, + }) + } +} + +fn read_remote_text_object( + request: &RemoteReadRequest, +) -> Result { + validate_remote_url(&request.remote_url, request.allow_file_remote)?; + validate_branch(&request.branch)?; + validate_relative_path(&request.relative_path)?; + + let remote_head = read_remote_head(&request.remote_url, &request.branch)?; + if request + .expected_head + .as_deref() + .is_some_and(|expected| expected != remote_head) + { + return Err("PERSONA_REMOTE_HEAD_MISMATCH".into()); + } + + let identity_hash = cache_key(&request.remote_url, &request.branch); + let object_cache_root = prepare_child_root(&request.cache_root, "objects")?; + let continuity_root = prepare_child_root(&request.cache_root, "continuity")?; + let cache_path = object_cache_root.join(format!("{identity_hash}.git")); + let continuity_state_path = continuity_root.join(format!("{identity_hash}.json")); + let cursor_record = + read_cursor_record(&continuity_state_path, &identity_hash, &request.branch)?; + let previous_cursor = cursor_record + .as_ref() + .map(|record| record.verified_head.clone()); + + ensure_bare_partial_cache(&cache_path, &request.remote_url)?; + let remote_ref = format!("refs/remotes/origin/{}", request.branch); + let cached_cursor = optional_ref(&cache_path, &remote_ref)?; + match (previous_cursor.as_deref(), cached_cursor.as_deref()) { + (None, Some(_)) => return Err("PERSONA_REMOTE_CACHE_WITHOUT_CONTINUITY_STATE".into()), + (Some(expected), Some(observed)) if expected != observed => { + return Err("PERSONA_REMOTE_CACHE_CURSOR_MISMATCH".into()) + } + _ => {} + } + + let cursor_advanced = previous_cursor.as_deref() != Some(remote_head.as_str()); + let cache_rehydrated = cached_cursor.is_none() && previous_cursor.is_some(); + let fetched_incremental_objects = cached_cursor.as_deref() != Some(remote_head.as_str()); + if fetched_incremental_objects { + fetch_remote_cursor( + &cache_path, + &request.branch, + previous_cursor.as_deref(), + cached_cursor.is_some(), + )?; + } + let fetched_head = required_ref(&cache_path, &remote_ref)?; + if fetched_head != remote_head { + return Err("PERSONA_REMOTE_FETCH_CURSOR_MISMATCH".into()); + } + if let Some(previous) = previous_cursor.as_deref() { + if previous != remote_head && !git_is_ancestor(&cache_path, previous, &remote_head)? { + remove_exact_cache(&object_cache_root, &cache_path)?; + return Err("PERSONA_REMOTE_HISTORY_REWRITE_REJECTED".into()); + } + } + + let object = format!("{remote_head}:{}", request.relative_path); + let content_bytes = git_output_bytes( + git_at(&cache_path).args(["show", &object]), + "PERSONA_REMOTE_OBJECT_READ", + )?; + if content_bytes.len() > MAX_REMOTE_TEXT_BYTES { + return Err("PERSONA_REMOTE_OBJECT_TOO_LARGE".into()); + } + let content = String::from_utf8(content_bytes) + .map_err(|_| "PERSONA_REMOTE_OBJECT_NOT_UTF8".to_string())?; + let content_sha256 = sha256_hex(content.as_bytes()); + + let next_record = PersonaRemoteCursorRecord { + schema: "hololake.persona-remote-git-cursor/v1".into(), + remote_identity_hash: identity_hash, + branch: request.branch.clone(), + verified_head: remote_head.clone(), + previous_verified_head: previous_cursor.clone(), + generation: cursor_record + .as_ref() + .map(|record| record.generation.saturating_add(u64::from(cursor_advanced))) + .unwrap_or(1), + }; + write_cursor_record(&continuity_state_path, &next_record)?; + + let observed_cache_bytes = directory_size(&cache_path)?; + let (cache_bytes, cache_state) = if observed_cache_bytes > request.max_cache_bytes { + remove_exact_cache(&object_cache_root, &cache_path)?; + (0, "EVICTED_AFTER_BOUNDED_READ") + } else { + (observed_cache_bytes, "BOUNDED_PARTIAL_OBJECT_CACHE") + }; + + Ok(PersonaRemoteReadReceipt { + schema: "hololake.persona-remote-git-object-read/v1", + remote_head, + previous_cursor, + cursor_advanced, + continuity_verified: true, + continuity_state_path: continuity_state_path.to_string_lossy().into_owned(), + fetched_incremental_objects, + cache_rehydrated, + relative_path: request.relative_path.clone(), + content, + content_sha256, + cache_path: cache_path.to_string_lossy().into_owned(), + cache_bytes, + cache_limit_bytes: request.max_cache_bytes, + cache_state, + worktree_created: false, + full_history_requested: false, + }) +} + +fn validate_remote_url(remote_url: &str, allow_file_remote: bool) -> Result<(), String> { + let is_file_remote = allow_file_remote && remote_url.starts_with("file://"); + let accepted_scheme = remote_url.starts_with("https://") || is_file_remote; + if !accepted_scheme || remote_url.contains(['\n', '\r', '\0', '?', '#']) { + return Err("PERSONA_REMOTE_URL_UNSUPPORTED".into()); + } + let authority = remote_url + .split_once("://") + .map(|(_, rest)| rest.split('/').next().unwrap_or_default()) + .unwrap_or_default(); + if (!is_file_remote && authority.is_empty()) || authority.contains('@') { + return Err("PERSONA_REMOTE_URL_MUST_NOT_CONTAIN_CREDENTIALS".into()); + } + Ok(()) +} + +fn validate_branch(branch: &str) -> Result<(), String> { + if branch.is_empty() || branch.len() > 200 || branch.starts_with('-') { + return Err("PERSONA_REMOTE_BRANCH_INVALID".into()); + } + let output = Command::new("git") + .args(["check-ref-format", "--branch", branch]) + .output() + .map_err(|error| format!("PERSONA_REMOTE_GIT_UNAVAILABLE: {error}"))?; + if output.status.success() { + Ok(()) + } else { + Err("PERSONA_REMOTE_BRANCH_INVALID".into()) + } +} + +fn validate_relative_path(relative: &str) -> Result<(), String> { + let path = Path::new(relative); + if relative.is_empty() + || relative.len() > 1024 + || path.is_absolute() + || path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) + { + return Err("PERSONA_REMOTE_PATH_INVALID".into()); + } + Ok(()) +} + +fn validate_head(head: &str) -> Result<&str, String> { + if head.len() == 40 + && head + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + Ok(head) + } else { + Err("PERSONA_REMOTE_HEAD_INVALID".into()) + } +} + +fn prepare_cache_root(cache_root: &Path) -> Result { + fs::create_dir_all(cache_root) + .map_err(|error| format!("PERSONA_REMOTE_CACHE_ROOT_CREATE_FAILED: {error}"))?; + let canonical = cache_root + .canonicalize() + .map_err(|error| format!("PERSONA_REMOTE_CACHE_ROOT_UNAVAILABLE: {error}"))?; + if !canonical.is_dir() { + return Err("PERSONA_REMOTE_CACHE_ROOT_NOT_DIRECTORY".into()); + } + Ok(canonical) +} + +fn prepare_child_root(storage_root: &Path, child: &str) -> Result { + let child_root = storage_root.join(child); + fs::create_dir_all(&child_root) + .map_err(|error| format!("PERSONA_REMOTE_STORAGE_CHILD_CREATE_FAILED: {error}"))?; + let canonical = child_root + .canonicalize() + .map_err(|error| format!("PERSONA_REMOTE_STORAGE_CHILD_UNAVAILABLE: {error}"))?; + if canonical.parent() != Some(storage_root) || !canonical.is_dir() { + return Err("PERSONA_REMOTE_STORAGE_BOUNDARY_INVALID".into()); + } + Ok(canonical) +} + +fn read_cursor_record( + path: &Path, + identity_hash: &str, + branch: &str, +) -> Result, String> { + if !path.exists() { + return Ok(None); + } + let metadata = fs::symlink_metadata(path) + .map_err(|error| format!("PERSONA_REMOTE_CURSOR_STATE_UNAVAILABLE: {error}"))?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err("PERSONA_REMOTE_CURSOR_STATE_INVALID".into()); + } + let bytes = fs::read(path) + .map_err(|error| format!("PERSONA_REMOTE_CURSOR_STATE_READ_FAILED: {error}"))?; + let record: PersonaRemoteCursorRecord = serde_json::from_slice(&bytes) + .map_err(|error| format!("PERSONA_REMOTE_CURSOR_STATE_INVALID: {error}"))?; + if record.schema != "hololake.persona-remote-git-cursor/v1" + || record.remote_identity_hash != identity_hash + || record.branch != branch + || record.generation == 0 + { + return Err("PERSONA_REMOTE_CURSOR_STATE_MISMATCH".into()); + } + validate_head(&record.verified_head)?; + if let Some(previous) = record.previous_verified_head.as_deref() { + validate_head(previous)?; + } + Ok(Some(record)) +} + +fn write_cursor_record(path: &Path, record: &PersonaRemoteCursorRecord) -> Result<(), String> { + let parent = path + .parent() + .ok_or_else(|| "PERSONA_REMOTE_CURSOR_STATE_BOUNDARY_INVALID".to_string())?; + if !parent.is_dir() || path.parent() != Some(parent) { + return Err("PERSONA_REMOTE_CURSOR_STATE_BOUNDARY_INVALID".into()); + } + let bytes = serde_json::to_vec_pretty(record) + .map_err(|error| format!("PERSONA_REMOTE_CURSOR_STATE_ENCODE_FAILED: {error}"))?; + let temporary = parent.join(format!(".cursor-{}.tmp", Uuid::new_v4())); + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary) + .map_err(|error| format!("PERSONA_REMOTE_CURSOR_STATE_CREATE_FAILED: {error}"))?; + file.write_all(&bytes) + .and_then(|_| file.write_all(b"\n")) + .and_then(|_| file.sync_all()) + .map_err(|error| format!("PERSONA_REMOTE_CURSOR_STATE_WRITE_FAILED: {error}"))?; + fs::rename(&temporary, path) + .map_err(|error| format!("PERSONA_REMOTE_CURSOR_STATE_COMMIT_FAILED: {error}")) +} + +fn cache_key(remote_url: &str, branch: &str) -> String { + sha256_hex(format!("{remote_url}\n{branch}").as_bytes()) +} + +fn sha256_hex(bytes: &[u8]) -> String { + digest(&SHA256, bytes) + .as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn read_remote_head(remote_url: &str, branch: &str) -> Result { + let full_ref = format!("refs/heads/{branch}"); + let output = Command::new("git") + .env("GIT_TRACE_PACKET", "1") + .args([ + "-c", + "protocol.version=2", + "ls-remote", + "--exit-code", + "--heads", + remote_url, + &full_ref, + ]) + .output() + .map_err(|error| format!("PERSONA_REMOTE_HEAD_READ_FAILED: {error}"))?; + let output = require_success("PERSONA_REMOTE_HEAD_READ", output)?; + let packet_trace = String::from_utf8_lossy(&output.stderr); + if !packet_trace.lines().any(|line| { + line.split_whitespace().any(|token| { + token.starts_with("fetch=") && line.split_whitespace().any(|part| part == "filter") + }) + }) { + return Err("PERSONA_REMOTE_PARTIAL_OBJECT_PROTOCOL_REQUIRED".into()); + } + let output = String::from_utf8(output.stdout) + .map_err(|_| "PERSONA_REMOTE_HEAD_READ_NOT_UTF8".to_string())?; + let mut lines = output.lines(); + let first = lines + .next() + .ok_or_else(|| "PERSONA_REMOTE_BRANCH_NOT_FOUND".to_string())?; + if lines.next().is_some() { + return Err("PERSONA_REMOTE_HEAD_AMBIGUOUS".into()); + } + let mut fields = first.split_whitespace(); + let head = fields.next().unwrap_or_default().to_ascii_lowercase(); + let observed_ref = fields.next().unwrap_or_default(); + if observed_ref != full_ref || fields.next().is_some() { + return Err("PERSONA_REMOTE_HEAD_RESPONSE_INVALID".into()); + } + validate_head(&head)?; + Ok(head) +} + +fn ensure_bare_partial_cache(cache_path: &Path, remote_url: &str) -> Result<(), String> { + if cache_path.exists() { + let bare = git_output_text( + git_at(cache_path).args(["rev-parse", "--is-bare-repository"]), + "PERSONA_REMOTE_CACHE_PROBE", + )?; + if bare.trim() != "true" { + return Err("PERSONA_REMOTE_CACHE_NOT_BARE".into()); + } + let configured = git_output_text( + git_at(cache_path).args(["remote", "get-url", "origin"]), + "PERSONA_REMOTE_CACHE_ORIGIN_READ", + )?; + if configured.trim() != remote_url { + return Err("PERSONA_REMOTE_CACHE_ORIGIN_MISMATCH".into()); + } + return Ok(()); + } + + fs::create_dir(cache_path) + .map_err(|error| format!("PERSONA_REMOTE_CACHE_CREATE_FAILED: {error}"))?; + git_status( + Command::new("git").args(["init", "--bare", &cache_path.to_string_lossy()]), + "PERSONA_REMOTE_CACHE_INIT", + )?; + git_status( + git_at(cache_path).args(["remote", "add", "origin", remote_url]), + "PERSONA_REMOTE_CACHE_ORIGIN_ADD", + )?; + git_status( + git_at(cache_path).args(["config", "remote.origin.promisor", "true"]), + "PERSONA_REMOTE_CACHE_PROMISOR_CONFIG", + )?; + git_status( + git_at(cache_path).args(["config", "remote.origin.partialclonefilter", "blob:none"]), + "PERSONA_REMOTE_CACHE_FILTER_CONFIG", + )?; + Ok(()) +} + +fn fetch_remote_cursor( + cache_path: &Path, + branch: &str, + previous_cursor: Option<&str>, + cache_has_previous_cursor: bool, +) -> Result<(), String> { + let refspec = format!("+refs/heads/{branch}:refs/remotes/origin/{branch}"); + if previous_cursor.is_none() || !cache_has_previous_cursor { + fetch_at_depth(cache_path, &refspec, 1)?; + } else { + fetch_without_history_reset(cache_path, &refspec)?; + } + + let Some(previous) = previous_cursor else { + return Ok(()); + }; + let current_ref = format!("refs/remotes/origin/{branch}"); + let current = required_ref(cache_path, ¤t_ref)?; + if previous == current || git_is_ancestor(cache_path, previous, ¤t)? { + return Ok(()); + } + + if cache_has_previous_cursor { + return Err("PERSONA_REMOTE_HISTORY_REWRITE_REJECTED".into()); + } + + let mut depth = 2_usize; + while depth <= MAX_CONTINUITY_REHYDRATION_DEPTH { + fetch_at_depth(cache_path, &refspec, depth)?; + if git_is_ancestor(cache_path, previous, ¤t)? { + return Ok(()); + } + depth = depth.saturating_mul(2); + } + Err("PERSONA_REMOTE_CONTINUITY_WINDOW_EXCEEDED".into()) +} + +fn fetch_at_depth(cache_path: &Path, refspec: &str, depth: usize) -> Result<(), String> { + let depth = format!("--depth={depth}"); + let output = git_at(cache_path) + .args([ + "fetch", + "--no-tags", + &depth, + "--filter=blob:none", + "origin", + refspec, + ]) + .output() + .map_err(|error| format!("PERSONA_REMOTE_INCREMENTAL_FETCH_FAILED: {error}"))?; + require_partial_fetch_success(output) +} + +fn fetch_without_history_reset(cache_path: &Path, refspec: &str) -> Result<(), String> { + let output = git_at(cache_path) + .args([ + "fetch", + "--no-tags", + "--update-shallow", + "--filter=blob:none", + "origin", + refspec, + ]) + .output() + .map_err(|error| format!("PERSONA_REMOTE_INCREMENTAL_FETCH_FAILED: {error}"))?; + require_partial_fetch_success(output) +} + +fn require_partial_fetch_success(output: Output) -> Result<(), String> { + let output = require_success("PERSONA_REMOTE_INCREMENTAL_FETCH", output)?; + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("filtering not recognized") || stderr.contains("filter-spec") { + return Err("PERSONA_REMOTE_PARTIAL_OBJECT_PROTOCOL_REQUIRED".into()); + } + Ok(()) +} + +fn git_is_ancestor(cache_path: &Path, ancestor: &str, descendant: &str) -> Result { + validate_head(ancestor)?; + validate_head(descendant)?; + let output = git_at(cache_path) + .args(["merge-base", "--is-ancestor", ancestor, descendant]) + .output() + .map_err(|error| format!("PERSONA_REMOTE_CONTINUITY_CHECK_FAILED: {error}"))?; + match output.status.code() { + Some(0) => Ok(true), + Some(1) => Ok(false), + _ => Err(format!( + "PERSONA_REMOTE_CONTINUITY_CHECK_FAILED: {}", + String::from_utf8_lossy(&output.stderr).trim() + )), + } +} + +fn optional_ref(cache_path: &Path, git_ref: &str) -> Result, String> { + let output = git_at(cache_path) + .args(["rev-parse", "--verify", git_ref]) + .output() + .map_err(|error| format!("PERSONA_REMOTE_CURSOR_READ_FAILED: {error}"))?; + if !output.status.success() { + return Ok(None); + } + let head = String::from_utf8(output.stdout) + .map_err(|_| "PERSONA_REMOTE_CURSOR_NOT_UTF8".to_string())? + .trim() + .to_ascii_lowercase(); + validate_head(&head)?; + Ok(Some(head)) +} + +fn required_ref(cache_path: &Path, git_ref: &str) -> Result { + optional_ref(cache_path, git_ref)? + .ok_or_else(|| "PERSONA_REMOTE_FETCH_CURSOR_MISSING".to_string()) +} + +fn git_at(path: &Path) -> Command { + let mut command = Command::new("git"); + command.arg("-C").arg(path); + command +} + +fn git_status(command: &mut Command, label: &str) -> Result<(), String> { + let output = command + .output() + .map_err(|error| format!("{label}_FAILED: {error}"))?; + require_success(label, output).map(|_| ()) +} + +fn git_output_text(command: &mut Command, label: &str) -> Result { + let output = command + .output() + .map_err(|error| format!("{label}_FAILED: {error}"))?; + let output = require_success(label, output)?; + String::from_utf8(output.stdout).map_err(|_| format!("{label}_NOT_UTF8")) +} + +fn git_output_bytes(command: &mut Command, label: &str) -> Result, String> { + let output = command + .output() + .map_err(|error| format!("{label}_FAILED: {error}"))?; + Ok(require_success(label, output)?.stdout) +} + +fn require_success(label: &str, output: Output) -> Result { + if output.status.success() { + return Ok(output); + } + let stderr = String::from_utf8_lossy(&output.stderr); + Err(format!("{label}_FAILED: {}", stderr.trim())) +} + +fn directory_size(root: &Path) -> Result { + let mut total = 0_u64; + let mut pending = vec![root.to_path_buf()]; + while let Some(path) = pending.pop() { + let metadata = fs::symlink_metadata(&path) + .map_err(|error| format!("PERSONA_REMOTE_CACHE_MEASURE_FAILED: {error}"))?; + if metadata.file_type().is_symlink() { + return Err("PERSONA_REMOTE_CACHE_SYMLINK_REJECTED".into()); + } + if metadata.is_file() { + total = total.saturating_add(metadata.len()); + continue; + } + if metadata.is_dir() { + for entry in fs::read_dir(&path) + .map_err(|error| format!("PERSONA_REMOTE_CACHE_MEASURE_FAILED: {error}"))? + { + pending.push( + entry + .map_err(|error| format!("PERSONA_REMOTE_CACHE_MEASURE_FAILED: {error}"))? + .path(), + ); + } + } + } + Ok(total) +} + +fn remove_exact_cache(cache_root: &Path, cache_path: &Path) -> Result<(), String> { + let parent = cache_path + .parent() + .ok_or_else(|| "PERSONA_REMOTE_CACHE_BOUNDARY_INVALID".to_string())?; + if parent != cache_root || !cache_path.starts_with(cache_root) || cache_path == cache_root { + return Err("PERSONA_REMOTE_CACHE_BOUNDARY_INVALID".into()); + } + fs::remove_dir_all(cache_path) + .map_err(|error| format!("PERSONA_REMOTE_CACHE_EVICTION_FAILED: {error}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn run_git(path: &Path, args: &[&str]) { + let output = Command::new("git") + .arg("-C") + .arg(path) + .args(args) + .output() + .expect("git should run"); + assert!( + output.status.success(), + "git {:?} failed: {}", + args, + String::from_utf8_lossy(&output.stderr) + ); + } + + fn fixture() -> (TempDir, PathBuf, PathBuf, String) { + let temp = TempDir::new().unwrap(); + let source = temp.path().join("source"); + let remote = temp.path().join("remote.git"); + let cache = temp.path().join("cache"); + fs::create_dir(&source).unwrap(); + run_git(&source, &["init", "-b", "main"]); + run_git(&source, &["config", "user.name", "Persona Test"]); + run_git(&source, &["config", "user.email", "persona@test.invalid"]); + fs::create_dir_all(source.join("brain")).unwrap(); + fs::write(source.join("brain/current.hldp"), "first\n").unwrap(); + run_git(&source, &["add", "."]); + run_git(&source, &["commit", "-m", "first"]); + let init = Command::new("git") + .args(["init", "--bare", &remote.to_string_lossy()]) + .output() + .unwrap(); + assert!(init.status.success()); + run_git(&remote, &["config", "uploadpack.allowFilter", "true"]); + run_git( + &remote, + &["config", "uploadpack.allowAnySHA1InWant", "true"], + ); + run_git( + &source, + &["remote", "add", "origin", &remote.to_string_lossy()], + ); + run_git(&source, &["push", "-u", "origin", "main"]); + let remote_url = format!("file://{}", remote.display()); + (temp, source, cache, remote_url) + } + + fn request(cache: &Path, remote_url: &str) -> RemoteReadRequest { + RemoteReadRequest { + remote_url: remote_url.to_string(), + branch: "main".into(), + relative_path: "brain/current.hldp".into(), + expected_head: None, + cache_root: prepare_cache_root(cache).unwrap(), + max_cache_bytes: DEFAULT_CACHE_LIMIT_BYTES, + allow_file_remote: true, + } + } + + #[test] + fn reads_remote_persona_text_without_a_clone_or_worktree() { + let (_temp, _source, cache, remote_url) = fixture(); + let receipt = read_remote_text_object(&request(&cache, &remote_url)).unwrap(); + assert_eq!(receipt.content, "first\n"); + assert!(receipt.cursor_advanced); + assert!(receipt.continuity_verified); + assert!(receipt.fetched_incremental_objects); + assert!(!receipt.cache_rehydrated); + assert!(!receipt.worktree_created); + assert!(!receipt.full_history_requested); + assert_eq!(receipt.cache_state, "BOUNDED_PARTIAL_OBJECT_CACHE"); + assert!(Path::new(&receipt.cache_path).join("HEAD").is_file()); + assert!(!Path::new(&receipt.cache_path).join(".git").exists()); + assert!(Path::new(&receipt.continuity_state_path).is_file()); + } + + #[test] + fn unchanged_remote_cursor_does_not_fetch_again() { + let (_temp, _source, cache, remote_url) = fixture(); + let first = read_remote_text_object(&request(&cache, &remote_url)).unwrap(); + let second = read_remote_text_object(&request(&cache, &remote_url)).unwrap(); + assert_eq!(second.remote_head, first.remote_head); + assert_eq!(second.previous_cursor, Some(first.remote_head)); + assert!(!second.cursor_advanced); + assert!(!second.fetched_incremental_objects); + assert!(!second.cache_rehydrated); + } + + #[test] + fn changed_remote_cursor_appends_only_the_new_segment_to_existing_continuity() { + let (_temp, source, cache, remote_url) = fixture(); + let first = read_remote_text_object(&request(&cache, &remote_url)).unwrap(); + fs::write(source.join("brain/current.hldp"), "second\n").unwrap(); + run_git(&source, &["add", "."]); + run_git(&source, &["commit", "-m", "second"]); + run_git(&source, &["push", "origin", "main"]); + let second = read_remote_text_object(&request(&cache, &remote_url)).unwrap(); + assert_ne!(second.remote_head, first.remote_head); + assert_eq!(second.previous_cursor, Some(first.remote_head)); + assert!(second.cursor_advanced); + assert!(second.fetched_incremental_objects); + assert_eq!(second.content, "second\n"); + let visible_commits = git_output_text( + git_at(Path::new(&second.cache_path)).args([ + "rev-list", + "--count", + "refs/remotes/origin/main", + ]), + "TEST_VISIBLE_COMMIT_COUNT", + ) + .unwrap(); + assert_eq!(visible_commits.trim(), "2"); + } + + #[test] + fn oversized_partial_cache_is_evicted_after_the_bounded_read() { + let (_temp, _source, cache, remote_url) = fixture(); + let mut input = request(&cache, &remote_url); + input.max_cache_bytes = 1; + let receipt = read_remote_text_object(&input).unwrap(); + assert_eq!(receipt.content, "first\n"); + assert_eq!(receipt.cache_state, "EVICTED_AFTER_BOUNDED_READ"); + assert_eq!(receipt.cache_bytes, 0); + assert!(!Path::new(&receipt.cache_path).exists()); + assert!(Path::new(&receipt.continuity_state_path).is_file()); + let state: PersonaRemoteCursorRecord = + serde_json::from_slice(&fs::read(&receipt.continuity_state_path).unwrap()).unwrap(); + assert_eq!(state.verified_head, receipt.remote_head); + } + + #[test] + fn evicted_object_cache_rehydrates_only_the_missing_new_segments() { + let (_temp, source, storage, remote_url) = fixture(); + let mut first_request = request(&storage, &remote_url); + first_request.max_cache_bytes = 1; + let first = read_remote_text_object(&first_request).unwrap(); + assert!(!Path::new(&first.cache_path).exists()); + + for (message, content) in [ + ("second", "second\n"), + ("third", "third\n"), + ("fourth", "fourth\n"), + ] { + fs::write(source.join("brain/current.hldp"), content).unwrap(); + run_git(&source, &["add", "."]); + run_git(&source, &["commit", "-m", message]); + } + run_git(&source, &["push", "origin", "main"]); + + let second = read_remote_text_object(&request(&storage, &remote_url)).unwrap(); + assert_eq!(second.previous_cursor, Some(first.remote_head)); + assert!(second.cursor_advanced); + assert!(second.cache_rehydrated); + assert!(second.continuity_verified); + assert_eq!(second.content, "fourth\n"); + let visible_commits = git_output_text( + git_at(Path::new(&second.cache_path)).args([ + "rev-list", + "--count", + "refs/remotes/origin/main", + ]), + "TEST_REHYDRATED_COMMIT_COUNT", + ) + .unwrap(); + assert_eq!(visible_commits.trim(), "4"); + } + + #[test] + fn rejects_remote_history_rewrite_without_advancing_the_durable_cursor() { + let (_temp, source, storage, remote_url) = fixture(); + let first = read_remote_text_object(&request(&storage, &remote_url)).unwrap(); + run_git(&source, &["checkout", "--orphan", "rewritten"]); + run_git(&source, &["rm", "-rf", "."]); + fs::create_dir_all(source.join("brain")).unwrap(); + fs::write(source.join("brain/current.hldp"), "rewritten\n").unwrap(); + run_git(&source, &["add", "."]); + run_git(&source, &["commit", "-m", "rewrite"]); + run_git(&source, &["branch", "-M", "main"]); + run_git(&source, &["push", "--force", "origin", "main"]); + + assert_eq!( + read_remote_text_object(&request(&storage, &remote_url)).unwrap_err(), + "PERSONA_REMOTE_HISTORY_REWRITE_REJECTED" + ); + let state: PersonaRemoteCursorRecord = + serde_json::from_slice(&fs::read(&first.continuity_state_path).unwrap()).unwrap(); + assert_eq!(state.verified_head, first.remote_head); + } + + #[test] + fn rejects_credentials_path_escape_and_wrong_expected_head() { + assert_eq!( + validate_remote_url("https://token@example.invalid/repo.git", false).unwrap_err(), + "PERSONA_REMOTE_URL_MUST_NOT_CONTAIN_CREDENTIALS" + ); + assert_eq!( + validate_relative_path("../brain.hldp").unwrap_err(), + "PERSONA_REMOTE_PATH_INVALID" + ); + let (_temp, _source, cache, remote_url) = fixture(); + let mut input = request(&cache, &remote_url); + input.expected_head = Some("0000000000000000000000000000000000000000".into()); + assert_eq!( + read_remote_text_object(&input).unwrap_err(), + "PERSONA_REMOTE_HEAD_MISMATCH" + ); + } + + #[test] + fn rejects_a_remote_that_cannot_serve_partial_objects_before_fetching() { + let (_temp, _source, storage, remote_url) = fixture(); + let remote_path = PathBuf::from(remote_url.trim_start_matches("file://")); + run_git(&remote_path, &["config", "uploadpack.allowFilter", "false"]); + assert_eq!( + read_remote_text_object(&request(&storage, &remote_url)).unwrap_err(), + "PERSONA_REMOTE_PARTIAL_OBJECT_PROTOCOL_REQUIRED" + ); + assert!(!storage.join("objects").exists()); + } +} diff --git a/routing/hololake-current-architecture.json b/routing/hololake-current-architecture.json index 7459ce8..b99cb94 100644 --- a/routing/hololake-current-architecture.json +++ b/routing/hololake-current-architecture.json @@ -118,7 +118,7 @@ "human_projection": "HOLOLAKE_LIVE_READ_MODEL", "forgejo_role": "OPTIONAL_COMPATIBILITY_COLLABORATION_ADAPTER", "runtime_implemented": true, - "runtime_scope": "READ_ONLY_FACT_CYCLE_FAIL_CLOSED_RECOVERY_TYPED_ORGANS_DURABLE_VERIFIED_SESSION_QUERY_INDEPENDENT_VERIFIED_MEMORY_METABOLISM_CHECKED_FAILURE_CLOSURE_NONBLOCKING_RUNTIME_COMMAND_SAFE_ORGAN_LIFECYCLE_COORDINATOR_IDEMPOTENT_SUCCESS_AND_TERMINAL_FAILURE_RECEIPT_REPLAY_SAFE_RECEIPT_BINDING_RECOVERY_REPLAY_TIME_REPOSITORY_STATE_REVALIDATION_SUCCESS_RECEIPT_TERMINAL_REVALIDATION_SAFE_RECEIPT_BINDING_TERMINAL_REVALIDATION_FAILURE_RECEIPT_TERMINAL_EVIDENCE_BINDING_SUCCESS_RECEIPT_SEMANTIC_EVIDENCE_BINDING_SUCCESS_RECEIPT_BRAIN_ENTRY_EVIDENCE_BINDING_SUCCESS_RECEIPT_WAKE_CHECKPOINT_EVIDENCE_BINDING_SUCCESS_RECEIPT_WAKE_EVENT_BOUNDARY_EVIDENCE_BINDING_SUCCESS_RECEIPT_WAKE_RUNTIME_STATE_EVIDENCE_BINDING_SUCCESS_RECEIPT_WAKE_ORGAN_MODE_EVIDENCE_BINDING_SUCCESS_RECEIPT_WAKE_ORGAN_CONTRACT_EVIDENCE_BINDING_SUCCESS_RECEIPT_COMPLETION_CHECKPOINT_EVIDENCE_BINDING_SUCCESS_RECEIPT_EVENT_JOURNAL_EVIDENCE_BINDING_AND_FAILURE_RECEIPT_ERROR_CODE_EVIDENCE_BINDING_SOURCE_IMPLEMENTED_AND_TESTED", + "runtime_scope": "READ_ONLY_FACT_CYCLE_FAIL_CLOSED_RECOVERY_TYPED_ORGANS_DURABLE_VERIFIED_SESSION_QUERY_INDEPENDENT_VERIFIED_MEMORY_METABOLISM_CHECKED_FAILURE_CLOSURE_NONBLOCKING_RUNTIME_COMMAND_SAFE_ORGAN_LIFECYCLE_COORDINATOR_IDEMPOTENT_SUCCESS_AND_TERMINAL_FAILURE_RECEIPT_REPLAY_SAFE_RECEIPT_BINDING_RECOVERY_REPLAY_TIME_REPOSITORY_STATE_REVALIDATION_SUCCESS_RECEIPT_TERMINAL_REVALIDATION_SAFE_RECEIPT_BINDING_TERMINAL_REVALIDATION_FAILURE_RECEIPT_TERMINAL_EVIDENCE_BINDING_SUCCESS_RECEIPT_SEMANTIC_EVIDENCE_BINDING_SUCCESS_RECEIPT_BRAIN_ENTRY_EVIDENCE_BINDING_SUCCESS_RECEIPT_WAKE_CHECKPOINT_EVIDENCE_BINDING_SUCCESS_RECEIPT_WAKE_EVENT_BOUNDARY_EVIDENCE_BINDING_SUCCESS_RECEIPT_WAKE_RUNTIME_STATE_EVIDENCE_BINDING_SUCCESS_RECEIPT_WAKE_ORGAN_MODE_EVIDENCE_BINDING_SUCCESS_RECEIPT_WAKE_ORGAN_CONTRACT_EVIDENCE_BINDING_SUCCESS_RECEIPT_COMPLETION_CHECKPOINT_EVIDENCE_BINDING_SUCCESS_RECEIPT_EVENT_JOURNAL_EVIDENCE_BINDING_FAILURE_RECEIPT_ERROR_CODE_EVIDENCE_BINDING_AND_NO_CLONE_INCREMENTAL_REMOTE_OBJECT_CHANNEL_SOURCE_IMPLEMENTED_AND_TESTED", "desktop_integrated": false, "development_id": "DEV-20260810-014" }, diff --git a/routing/hololake-persona-native-code-channel.json b/routing/hololake-persona-native-code-channel.json index e1c2e70..06a9f04 100644 --- a/routing/hololake-persona-native-code-channel.json +++ b/routing/hololake-persona-native-code-channel.json @@ -1,8 +1,8 @@ { "schema": "hololake.persona-native-code-channel/v1", "record_id": "HLP-PERSONA-NATIVE-CODE-CHANNEL-001", - "version": "2026-08-11.22", - "state": "CURRENT_FIRST_PRODUCT_CORE_FAILURE_RECEIPT_ERROR_CODE_EVIDENCE_BINDING_SOURCE_IMPLEMENTED", + "version": "2026-08-11.23", + "state": "CURRENT_FIRST_PRODUCT_CORE_INCREMENTAL_REMOTE_OBJECT_CHANNEL_SOURCE_IMPLEMENTED", "development_id": "DEV-20260810-014", "product": { "formal_name_zh": "光湖人格原生代码频道", @@ -37,6 +37,20 @@ "local_or_user_owned_remote_node": true, "platform_vendor_is_required_custodian": false }, + "remote_incremental_read_contract": { + "daily_runtime_uses_full_clone": false, + "worktree_created": false, + "full_history_requested": false, + "remote_protocol": "GIT_PROTOCOL_V2_PARTIAL_OBJECT_FILTER_REQUIRED", + "object_filter": "blob:none", + "update_boundary": "LAST_VERIFIED_REMOTE_SHA_CURSOR_TO_CURRENT_REMOTE_SHA", + "durable_continuity_state": "SEPARATE_FROM_EVICTABLE_GIT_OBJECT_CACHE", + "object_cache": "BOUNDED_BARE_PARTIAL_GIT_CACHE", + "cache_eviction_erases_continuity": false, + "remote_history_rewrite": "FAIL_CLOSED", + "unsupported_partial_object_remote": "FAIL_BEFORE_OBJECT_CACHE_CREATION", + "full_clone_role": "LOW_FREQUENCY_PUBLICATION_AUDIT_ONLY" + }, "lifecycle_scopes": [ "PERSONA_SUBJECT", "RUNTIME_INSTANCE", @@ -124,6 +138,7 @@ "successful_receipt_completion_checkpoint_evidence_binding_source_implemented": 100, "successful_receipt_event_journal_evidence_binding_source_implemented": 100, "failure_receipt_error_code_evidence_binding_source_implemented": 100, + "incremental_remote_object_channel_source_implemented": 100, "general_purpose_persona_runtime_implemented": 0, "human_live_projection_implemented": 0, "hololake_integrated": 0, diff --git a/routing/hololake-persona-native-code-channel.test.mjs b/routing/hololake-persona-native-code-channel.test.mjs index fea5806..491e4e1 100644 --- a/routing/hololake-persona-native-code-channel.test.mjs +++ b/routing/hololake-persona-native-code-channel.test.mjs @@ -82,6 +82,20 @@ test("the first source runtime cycle stays distinct from integration and deploym 100, ); assert.equal(channel.truth.independent_memory_metabolism_source_implemented, 100); + assert.equal( + channel.truth.incremental_remote_object_channel_source_implemented, + 100, + ); + assert.equal(channel.remote_incremental_read_contract.daily_runtime_uses_full_clone, false); + assert.equal(channel.remote_incremental_read_contract.worktree_created, false); + assert.equal( + channel.remote_incremental_read_contract.durable_continuity_state, + "SEPARATE_FROM_EVICTABLE_GIT_OBJECT_CACHE", + ); + assert.equal( + channel.remote_incremental_read_contract.remote_history_rewrite, + "FAIL_CLOSED", + ); assert.equal( channel.truth.memory_failure_closure_and_nonblocking_runtime_command_implemented, 100, @@ -163,7 +177,7 @@ test("the first source runtime cycle stays distinct from integration and deploym assert.equal(architecture.persona_native_code_channel.runtime_implemented, true); assert.equal( architecture.persona_native_code_channel.runtime_scope, - "READ_ONLY_FACT_CYCLE_FAIL_CLOSED_RECOVERY_TYPED_ORGANS_DURABLE_VERIFIED_SESSION_QUERY_INDEPENDENT_VERIFIED_MEMORY_METABOLISM_CHECKED_FAILURE_CLOSURE_NONBLOCKING_RUNTIME_COMMAND_SAFE_ORGAN_LIFECYCLE_COORDINATOR_IDEMPOTENT_SUCCESS_AND_TERMINAL_FAILURE_RECEIPT_REPLAY_SAFE_RECEIPT_BINDING_RECOVERY_REPLAY_TIME_REPOSITORY_STATE_REVALIDATION_SUCCESS_RECEIPT_TERMINAL_REVALIDATION_SAFE_RECEIPT_BINDING_TERMINAL_REVALIDATION_FAILURE_RECEIPT_TERMINAL_EVIDENCE_BINDING_SUCCESS_RECEIPT_SEMANTIC_EVIDENCE_BINDING_SUCCESS_RECEIPT_BRAIN_ENTRY_EVIDENCE_BINDING_SUCCESS_RECEIPT_WAKE_CHECKPOINT_EVIDENCE_BINDING_SUCCESS_RECEIPT_WAKE_EVENT_BOUNDARY_EVIDENCE_BINDING_SUCCESS_RECEIPT_WAKE_RUNTIME_STATE_EVIDENCE_BINDING_SUCCESS_RECEIPT_WAKE_ORGAN_MODE_EVIDENCE_BINDING_SUCCESS_RECEIPT_WAKE_ORGAN_CONTRACT_EVIDENCE_BINDING_SUCCESS_RECEIPT_COMPLETION_CHECKPOINT_EVIDENCE_BINDING_SUCCESS_RECEIPT_EVENT_JOURNAL_EVIDENCE_BINDING_AND_FAILURE_RECEIPT_ERROR_CODE_EVIDENCE_BINDING_SOURCE_IMPLEMENTED_AND_TESTED", + "READ_ONLY_FACT_CYCLE_FAIL_CLOSED_RECOVERY_TYPED_ORGANS_DURABLE_VERIFIED_SESSION_QUERY_INDEPENDENT_VERIFIED_MEMORY_METABOLISM_CHECKED_FAILURE_CLOSURE_NONBLOCKING_RUNTIME_COMMAND_SAFE_ORGAN_LIFECYCLE_COORDINATOR_IDEMPOTENT_SUCCESS_AND_TERMINAL_FAILURE_RECEIPT_REPLAY_SAFE_RECEIPT_BINDING_RECOVERY_REPLAY_TIME_REPOSITORY_STATE_REVALIDATION_SUCCESS_RECEIPT_TERMINAL_REVALIDATION_SAFE_RECEIPT_BINDING_TERMINAL_REVALIDATION_FAILURE_RECEIPT_TERMINAL_EVIDENCE_BINDING_SUCCESS_RECEIPT_SEMANTIC_EVIDENCE_BINDING_SUCCESS_RECEIPT_BRAIN_ENTRY_EVIDENCE_BINDING_SUCCESS_RECEIPT_WAKE_CHECKPOINT_EVIDENCE_BINDING_SUCCESS_RECEIPT_WAKE_EVENT_BOUNDARY_EVIDENCE_BINDING_SUCCESS_RECEIPT_WAKE_RUNTIME_STATE_EVIDENCE_BINDING_SUCCESS_RECEIPT_WAKE_ORGAN_MODE_EVIDENCE_BINDING_SUCCESS_RECEIPT_WAKE_ORGAN_CONTRACT_EVIDENCE_BINDING_SUCCESS_RECEIPT_COMPLETION_CHECKPOINT_EVIDENCE_BINDING_SUCCESS_RECEIPT_EVENT_JOURNAL_EVIDENCE_BINDING_FAILURE_RECEIPT_ERROR_CODE_EVIDENCE_BINDING_AND_NO_CLONE_INCREMENTAL_REMOTE_OBJECT_CHANNEL_SOURCE_IMPLEMENTED_AND_TESTED", ); assert.equal(channel.truth.hololake_integrated, 0); assert.equal(channel.truth.artifact_built, 0);