Skip to content

Feature request: shared Calendar update/delete and exact event readback for app agents #427

Open

Description

@9tuore

The built-in local Calendar provides shareable calendar.events, calendar.add_event and calendar.notify. Its actual update/delete implementations exist, but their descriptors omit shareable; there is no exact event-by-ID get tool. This is a compatibility feature request if that sharing policy is intentional, not a claim that an app should bypass consent or directly call the owning service.

Scope: OctoSense 3a4d1e1e557750eac69b412f34d36021306ea654. Current main tools blob 243c04aa0b5d027fd0a79435c73b18d16ade8beb and service blob 33f1a25f12960fa9db39dfc82de631bc40666866 were rechecked and are unchanged at this observation.

Use case: an authorized third-party app Agent reads a scheduling message, creates a local event, then receives a changed/cancelled instruction and must update/remove the same event and reconcile the resulting state. We specifically mean OctoSense's built-in local Calendar store, not Google Calendar, EventKit or device_calendar.

Minimal executed reproduction:

  • Load the original Calendar tools JSON into the original relay Catalog::grant/entry/shareable/may_call methods.
  • Explicitly grant calendar.update_event and calendar.remove_event to muse-goals. Both still return false from may_call, including with the developer flag; the owning os.calendar caller succeeds. calendar.events is a positive shared control, and an ungranted caller is a negative control. This is descriptor policy, not missing account authorization.
  • Run the original Calendar service handle: direct calls with app ID muse-goals are refused by the owner check. handle(os.calendar, "get", …) returns an unavailable-method error; it does not return precise absence.
  • A filtered calendar.events call can return an empty list while the exact event still exists. The list caps at 200 and has no ID filter/pagination descriptor, so absence from that result is insufficient deletion/reconciliation proof.

Execution layer: isolated original Rust logic, not a native Shell session. The pure Calendar core/UI/cards are unchanged; only the Host registration/import adapter is removed for the small harness. Four production relay methods are copied byte-for-byte into a minimal Catalog data container. No fake model/Calendar implementation is used. Seven existing upstream Calendar tests and two boundary tests passed. No native relay/consent/GUI approval or real model turn was executed; this does not establish native full-chain success.

Proposed local patch (not installed, submitted or accepted):

  1. Mark existing calendar.update_event and calendar.remove_event shareable. Keep all per-tool grants, owning executor, account/admission/Agent consent and approval handling. Keep delete risk: destructive, confirm: host, and existing update expected stale-state check.
  2. Add a read-only shareable calendar.get_event with exact id, returning {found, event}. An absent ID returns found:false; corrupt/unreadable storage is an error, not false absence. Return the saved event for caller-side exact field comparison; do not claim delivery/synchronization.
  3. Include regression tests for same-ID update/readback, stale update refusal, delete/readback absence, lookup beyond the list cap and corrupt-store error.

The final local patched pure-core suite passed 9 tests (7 unchanged + 2 new), and one relay policy test passed. One test of the proposed seven descriptors passed against the unmodified actual App Hub ToolManifest::validate. Patch application was checked on fixed original files. None of these tests modifies a Gate or proves signed installation/native approval/model choice. Upstream maintainers should review the sharing policy and native consent/confirmation behavior before adopting it.

Related: #267 introduced Mail/Calendar agent cards; #411 and #382 concern engine/system agent reach. Searches for calendar shareable, calendar update_event, calendar remove_event and calendar get_event were repeated. No exact competing shared-update/get proposal was found in those captured results; this is scoped search, not exhaustive proof. Please link an existing design if this owner-only policy is intentional.

No private events, contacts, credentials, provider keys or personal store contents are needed: all executed service data was synthetic in temporary directories. This request does not ask for reserved identity impersonation, a custom tool loop, or deletion/recreation as an update substitute.

Activity

  1. 9tuore commented on Oct 9, 2026

    @9tuore
    Author

    Follow-up implementation proposal for #427: shared Calendar reconciliation and exact saved-event lookup

    This revises the existing feature request, not a new issue. #427 is OPEN; the patch is not installed or accepted upstream. I am attaching the tested narrow implementation below for maintainer review; this is not a production integration claim.

    The current apps/AGENTS.md explicitly says that calendar.update_event belongs to Calendar's UI and own Agent, not new Mail/system grants. Accordingly, sharing update/delete is a requested policy change for maintainers to review, not a fix for an accidentally missing permission. No caller gets a tool merely by asking for it.

    The attached patch changes exactly two files at OctoSense 3a4d1e1e557750eac69b412f34d36021306ea654:

    • apps/calendar/host-service/src/lib.rs: add read-only exact-ID get_event and two regression tests. Unreadable/corrupt storage is an error; an absent ID returns {found:false,event:{}} instead of relying on a bounded list. Existing owner guard, stale-state update behavior and service registration remain unchanged.
    • apps/calendar/bundle/tools.json: declare calendar.get_event; propose sharing the existing update/delete tools. Delete keeps risk: destructive and confirm: host. Per-tool grants, signed admission, account/Agent consent, owning executor and Host approval mechanisms remain required. This intentionally broadens the shareable surface if adopted.

    Validation actually run after normalizing the added Rust into readable blocks:

    Check Result Execution layer
    Original core baseline 7 existing + 2 boundary tests passed Isolated original Calendar/UI/cards logic and four exact relay methods
    Final revised patch 9 core tests + 1 grant/owner policy test passed Isolated patched core; includes same-ID update/readback, stale refusal, delete/absence, list cap and corrupt-store errors
    Proposed seven descriptors 1 test passed Unmodified official Hub ToolManifest::validate
    Patch application/scope Passed Exactly the two files above; applied core/descriptors match the tested candidate

    The harness removes only the Host registration/import adapter to avoid building a complete Shell. It is not a replacement runtime. Original and patched harness packages now have distinct Cargo names: a preliminary rerun omitted the two new test names despite exit 0, so that output was treated as insufficient and preserved; the corrected run explicitly lists both new tests. No omitted tests are counted as passes. Rustfmt is unavailable in the installed toolchain, so formatting was reviewed manually; no rustfmt success is claimed.

    Unverified: complete official workspace/service build, native Shell cold-start, admission offer registration, real Agent consent/approval routing, actual model-selected calls, device/UI acceptance and real scheduling effects. No Gate changes, identity impersonation, secret/private event data, provider calls or production integration are included.

    Official root/apps AGENTS.md requirements were read at the fixed revision (no applicable CONTRIBUTING.md or Calendar rustfmt configuration was found in its tree). Before adoption, maintainers must review the owner-only policy change, update the requesting app's grants and shipped admission offer, update English/Chinese documentation together, and run the documented workspace/service/Shell acceptance checks. Those integration/docs changes are intentionally outside this narrow two-file review patch. Full official CI/setup checks were not run by this isolated test task. Any public Git commit should use the public identity required by the root instructions.

    Patch SHA256: e5545d88382f689faf99d050f0053c4bdf7a99486dc07384195314880f1a4d9b.

    The two affected files have the same Git blob IDs in official Desktop RC2 (4ccf8e068399b1da139771a9ed94cef05fa6ae60) and the audited source revision. This was checked against the recursive Git trees; the tests remain bound to the audited revision above.

    Two-file patch (implementation and regression tests)
    --- a/apps/calendar/host-service/src/lib.rs
    +++ b/apps/calendar/host-service/src/lib.rs
    @@ -276,6 +276,7 @@
         };
         match method {
             "view" => ui::view(host_dir, args, now),
    +        "get_event" => get_event(host_dir, args),
             "update_event" => {
                 let mut result = ui::update(host_dir,args)?;
                 let event: Event = serde_json::from_value(result["event"].clone()).expect("saved event");
    @@ -407,6 +408,24 @@
         }
     }
     
    +/// Exact saved-record lookup; never turn unreadable/corrupt storage into absence.
    +fn get_event(host_dir: &Path, args: &Value) -> Result<Value, String> {
    +    let id = bounded(text(args, "id"), 64, "An event id")?;
    +    if id.is_empty() {
    +        return Err("Give the saved event id.".into());
    +    }
    +    let events: Vec<Event> = match std::fs::read(store_path(host_dir)) {
    +        Ok(bytes) => serde_json::from_slice(&bytes)
    +            .map_err(|e| format!("Calendar cannot read its saved events: {e}"))?,
    +        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
    +        Err(e) => return Err(format!("Calendar cannot read its saved events: {e}")),
    +    };
    +    Ok(match events.into_iter().find(|event| event.id == id) {
    +        Some(event) => json!({"found": true, "event": event}),
    +        None => json!({"found": false, "event": {}}),
    +    })
    +}
    +
     /// The service the Card runner (and the agent's tools) call.
     pub struct CalendarService;
     
    @@ -538,3 +557,96 @@
             let _ = std::fs::remove_dir_all(&d);
         }
     }
    +
    +#[cfg(test)]
    +mod reconciliation_tests {
    +    use super::*;
    +
    +    #[test]
    +    fn exact_readback_update_stale_refusal_delete_and_absence() {
    +        let dir = std::env::temp_dir().join(format!(
    +            "muse-calendar-patch-chain-{}",
    +            std::process::id()
    +        ));
    +        let _ = std::fs::remove_dir_all(&dir);
    +        let now = parse_time("2026-10-09T09:00").unwrap();
    +        let absent = handle(APP, "get_event", &json!({"id": "absent"}), &dir, now, None).unwrap();
    +        assert_eq!(absent["found"], false);
    +
    +        let args = json!({
    +            "title": "Synthetic delivery",
    +            "start": "2026-10-10T09:00",
    +            "timezone": "Asia/Shanghai",
    +            "request_id": "synthetic-patch-one"
    +        });
    +        let added = handle(APP, "add_event", &args, &dir, now, None).unwrap();
    +        let id = added["id"].clone();
    +        let first = handle(APP, "get_event", &json!({"id": id}), &dir, now, None).unwrap();
    +        assert_eq!(first["found"], true);
    +        assert_eq!(first["event"]["request_id"], args["request_id"]);
    +
    +        let update = json!({
    +            "id": id,
    +            "expected": first["event"],
    +            "title": "Synthetic moved",
    +            "start": "2026-10-10T10:00",
    +            "timezone": "Asia/Shanghai"
    +        });
    +        let saved = handle(APP, "update_event", &update, &dir, now, None).unwrap();
    +        assert_eq!(saved["event"]["id"], id);
    +        let stale = handle(APP, "update_event", &update, &dir, now, None).unwrap_err();
    +        assert!(stale.contains("changed elsewhere"));
    +
    +        let read = handle(APP, "get_event", &json!({"id": id}), &dir, now, None).unwrap();
    +        assert_eq!(read["event"], saved["event"]);
    +        handle(
    +            APP,
    +            "remove_event",
    +            &json!({"id": id, "expected": read["event"]}),
    +            &dir,
    +            now,
    +            None,
    +        )
    +        .unwrap();
    +        let removed = handle(APP, "get_event", &json!({"id": id}), &dir, now, None).unwrap();
    +        assert_eq!(removed["found"], false);
    +        std::fs::remove_dir_all(dir).unwrap();
    +    }
    +
    +    #[test]
    +    fn exact_query_beyond_list_cap_and_corrupt_store_never_absent() {
    +        let dir = std::env::temp_dir().join(format!(
    +            "muse-calendar-patch-bounds-{}",
    +            std::process::id()
    +        ));
    +        let _ = std::fs::remove_dir_all(&dir);
    +        std::fs::create_dir_all(dir.join("calendar")).unwrap();
    +        let events: Vec<Event> = (0..201)
    +            .map(|i| Event {
    +                id: format!("ev-{i:03}"),
    +                title: "Synthetic".into(),
    +                start: "2026-10-10T09:00".into(),
    +                end: None,
    +                location: String::new(),
    +                notes: String::new(),
    +                request_id: String::new(),
    +                timezone: String::new(),
    +            })
    +            .collect();
    +        let path = dir.join("calendar/events.json");
    +        std::fs::write(&path, serde_json::to_vec(&events).unwrap()).unwrap();
    +        let now = parse_time("2026-10-09T09:00").unwrap();
    +
    +        let list = handle(APP, "events", &json!({"limit": 200}), &dir, now, None).unwrap();
    +        let rows = list["events"].as_array().unwrap();
    +        assert_eq!(rows.len(), 200);
    +        assert!(!rows.iter().any(|event| event["id"] == "ev-200"));
    +        let exact = handle(APP, "get_event", &json!({"id": "ev-200"}), &dir, now, None).unwrap();
    +        assert_eq!(exact["found"], true);
    +
    +        std::fs::write(path, b"not json").unwrap();
    +        assert!(handle(APP, "get_event", &json!({"id": "absent"}), &dir, now, None).is_err());
    +        assert!(handle(APP, "get_event", &json!({"id": ""}), &dir, now, None).is_err());
    +        std::fs::remove_dir_all(dir).unwrap();
    +    }
    +}
    --- a/apps/calendar/bundle/tools.json
    +++ b/apps/calendar/bundle/tools.json
    @@ -44,6 +44,44 @@
           "shareable": true
         },
         {
    +      "name": "calendar.get_event",
    +      "description": "Read one saved local Calendar event by exact id, including an explicit absence result. Unreadable storage is an error, never absence.",
    +      "input_schema": {
    +        "type": "object",
    +        "properties": {
    +          "id": {
    +            "type": "string",
    +            "minLength": 1,
    +            "maxLength": 64
    +          }
    +        },
    +        "required": [
    +          "id"
    +        ],
    +        "additionalProperties": false
    +      },
    +      "output_schema": {
    +        "type": "object",
    +        "properties": {
    +          "found": {
    +            "type": "boolean"
    +          },
    +          "event": {
    +            "type": "object"
    +          }
    +        },
    +        "required": [
    +          "found",
    +          "event"
    +        ],
    +        "additionalProperties": false
    +      },
    +      "risk": "read",
    +      "implemented_by": "host-service",
    +      "shareable": true,
    +      "private_data": true
    +    },
    +    {
           "name": "calendar.add_event",
           "description": "Add an event to the person's calendar. start (and end) are local times like 2026-10-02T15:00, or a date like 2026-10-02 for the whole day. Answers the new event's id. For retries supply the same stable request_id and exact event fields; it reuses the saved event. Changed fields with that key are refused instead of duplicating it. This is OctoSense local storage, not Google Calendar sync. Supply timezone (IANA, e.g. America/Los_Angeles) to preserve the event wall time independently of device settings. Missing timezone uses device local time. Do not guess an unspecified end.",
           "input_schema": {
    @@ -170,7 +208,8 @@
           },
           "risk": "act",
           "background": true,
    -      "implemented_by": "host-service"
    +      "implemented_by": "host-service",
    +      "shareable": true
         },
         {
           "name": "calendar.remove_event",
    @@ -203,7 +242,8 @@
           "risk": "destructive",
           "confirm": "host",
           "background": true,
    -      "implemented_by": "host-service"
    +      "implemented_by": "host-service",
    +      "shareable": true
         },
         {
           "name": "calendar.notify",
  2. 9tuore commented on Oct 10, 2026

    @9tuore
    Author

    RC2 follow-up: real built-in Calendar service/UI validation and a fail-closed reconciliation patch

    This is a follow-up to #427, not a new issue or a claim that Store-app Calendar admission is accepted. Calendar's owner-only update policy in apps/AGENTS.md remains authoritative until maintainers review a change.

    Fixed sources: Desktop RC2 4ccf8e0; App Hub 95e4831afca7227b640f075b252c04355bb63865. This round compiled the byte-identical original Calendar with the actual prepared Appstore library (no HostService import shim). Seven original tests passed. Independent-process synthetic CRUD, same-ID updates, retry protection, stale-snapshot rejection and durable-file readback passed 11 checks. The unchanged Calendar Splash UI also passed actual visible creation, editing, restart restoration, two-click delete and restart-absence checks on an isolated reference card host. That reference uses --system only for the shipped os.calendar bundle, never for Muse; it is not a full installed Shell session.

    A new fault reproduction shows original load(...).unwrap_or_default() treating malformed events.json as empty: add_event then overwrites the damaged file. This was reproduced only in a synthetic isolated directory; original bytes and failed tests are retained. A plain empty list must not establish absence under this fault.

    The revised narrow patch below:

    • Adds exact get_event by either event ID or durable request_id, with {found,event}; authoritative absence uses event:null, unreadable/ambiguous records are errors.
    • Uses fallible store reads for all Calendar tool/UI reads and mutations, preserving unreadable files instead of silently overwriting them.
    • Proposes shared update/remove with explicit per-tool grants still required. Existing owner guard and update expected-record comparison stay unchanged. Shared remove requires expected in its schema.
    • Requests host confirmation for both updates of existing records and deletion, with auto_approvable:false. The actual official ToolSpec::supervision treats risk:act as unattended even with confirm:host; the first test caught this. The revised update declaration is destructive to preserve person-confirmation semantics. This risk/sharing change needs maintainer approval, not an app's self-grant.

    Final proposed service: 10 Rust tests passed (7 unchanged + 3 new), 20 independent-process/policy/schema checks and 7 precise-reconciliation/fault checks passed. The unchanged Calendar UI against the explicitly identified patched service passed 7 visible checks. Original and patched Host SHA/source identities are separated. Earlier fixture/signature/risk failures are preserved.

    The unmodified App Hub API HostLimits::with_offered_tools positively resolves a narrowly offered calendar.events request while retaining app ID muse-goals; unoffered delete, digest tampering and missing shipping signatures remain refused. This is only a trusted-host policy/digest component test using an explicit unsigned developer fixture, not Store catalog admission or a Shell grant. #182 still covers the missing Store-app admission-offer path. No system identity impersonation, altered signature checks, budget increase, private event/mail data, provider call, second runtime or installed host replacement is included.

    Still unverified/BLOCKED: official installed third-party Calendar relay, real model-selected tool calls and native host approval in a full Shell. The latest debug reference build also reproduced Muse startup's 64ms script time limit; that failure is preserved and not described as full-chain success.

    Local reproducibility scripts and evidence are in the Muse development branch under official_muse/semifinal/tests and evidence/pivot-builtin-20261010; transport synchronization is being independently verified. No main/tag/release was changed. Patch SHA256: f40635f6373dd68ea295f5c1c0bf964c1f044ce14dc268df0931be8a809c2ad8.

    Three-file RC2 review patch including regression tests
    --- a/apps/calendar/host-service/src/lib.rs
    +++ b/apps/calendar/host-service/src/lib.rs
    @@ -181,12 +181,21 @@
     
     /// The events kept under `host_dir`, soonest first.
     pub fn load(host_dir: &Path) -> Vec<Event> {
    -    let mut events: Vec<Event> = std::fs::read(store_path(host_dir))
    -        .ok()
    -        .and_then(|b| serde_json::from_slice(&b).ok())
    -        .unwrap_or_default();
    +    // Compatibility for existing callers; all tool and UI operations use
    +    // the fallible reader below and never treat an unreadable store as empty.
    +    load_checked(host_dir).unwrap_or_default()
    +}
    +
    +fn load_checked(host_dir: &Path) -> Result<Vec<Event>, String> {
    +    let bytes = match std::fs::read(store_path(host_dir)) {
    +        Ok(bytes) => bytes,
    +        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
    +        Err(error) => return Err(format!("Calendar cannot read its events: {error}")),
    +    };
    +    let mut events: Vec<Event> = serde_json::from_slice(&bytes)
    +        .map_err(|_| "Calendar events are unreadable. Keep the original file and repair it before editing.".to_string())?;
         events.sort_by_cached_key(|e| (e.local_stamp(false), e.id.clone()));
    -    events
    +    Ok(events)
     }
     
     fn save(host_dir: &Path, events: &[Event]) -> Result<(), String> {
    @@ -282,6 +291,17 @@
                 if let Err(error) = cards::refresh(host_dir,&event,publish) { result["card_warning"] = json!(error); }
                 Ok(result)
             },
    +        "get_event" => {
    +            let id = bounded(text(args, "id"), 64, "An event id")?;
    +            let request_id = bounded(text(args, "request_id"), 160, "A request id")?;
    +            if (id.is_empty() && request_id.is_empty()) || (!id.is_empty() && !request_id.is_empty()) {
    +                return Err("Supply exactly one event id or request_id for precise readback.".into());
    +            }
    +            let matches: Vec<Event> = load_checked(host_dir)?.into_iter()
    +                .filter(|event| if id.is_empty() { event.request_id == request_id } else { event.id == id }).collect();
    +            if matches.len() > 1 { return Err("Calendar event identity is ambiguous. Keep the records and reconcile them.".into()); }
    +            Ok(json!({"found": !matches.is_empty(), "event": matches.first()}))
    +        },
             "events" => {
                 let from = match text(args, "from") {
                     "" => None,
    @@ -292,7 +312,7 @@
                     s => Some(stamp(parse_time(s)?)),
                 };
                 let limit = args["limit"].as_u64().unwrap_or(50).clamp(1, 200) as usize;
    -            let events: Vec<Event> = load(host_dir)
    +            let events: Vec<Event> = load_checked(host_dir)?
                     .into_iter()
                     .filter(|e| from.as_ref().map_or(true, |f| e.local_stamp(true) >= *f))
                     .filter(|e| to.as_ref().map_or(true, |t| e.local_stamp(false) <= *t))
    @@ -327,7 +347,7 @@
                     request_id: bounded(text(args, "request_id"), 160, "A request id")?,
                     timezone,
                 };
    -            let mut events = load(host_dir);
    +            let mut events = load_checked(host_dir)?;
                 if !event.request_id.is_empty() {
                     if let Some(existing) = events.iter().find(|e| e.request_id == event.request_id) {
                         let mut same = event.clone(); same.id = existing.id.clone();
    @@ -348,7 +368,7 @@
             }
             "remove_event" => {
                 let id = text(args, "id");
    -            let mut events = load(host_dir);
    +            let mut events = load_checked(host_dir)?;
                 if let Some(expected) = args.get("expected") {
                     let expected: Event = serde_json::from_value(expected.clone()).map_err(|_| "Read the event before deleting it.")?;
                     if !events.iter().any(|e| e.id == id && *e == expected) { return Err("This event changed elsewhere. Reload before deleting it.".into()); }
    @@ -366,7 +386,7 @@
             "notify" => {
                 let priority = args["priority"].as_i64().unwrap_or(70);
                 if !text(args,"event").is_empty() {
    -                let event = load(host_dir).into_iter().find(|e| e.id == text(args,"event")).ok_or("This event is no longer in Calendar.")?;
    +                let event = load_checked(host_dir)?.into_iter().find(|e| e.id == text(args,"event")).ok_or("This event is no longer in Calendar.")?;
                     let card_id = if text(args,"card_id").is_empty() { &event.id } else { text(args,"card_id") };
                     let publisher = publish.ok_or("This device shows no glance cards.")?;
                     return cards::publish(host_dir,&event,card_id,priority,chrono::Utc::now().timestamp_millis().max(0) as u64,publisher);
    @@ -384,7 +404,7 @@
                         (title, day_time, bounded(text(args, "location"), PLACE_MAX, "A place")?, bounded(text(args, "notes"), NOTES_MAX, "Notes")?, id)
                     }
                     id => {
    -                    let event = load(host_dir).into_iter().find(|e| e.id == id).ok_or_else(|| format!("There is no event {id:?}."))?;
    +                    let event = load_checked(host_dir)?.into_iter().find(|e| e.id == id).ok_or_else(|| format!("There is no event {id:?}."))?;
                         let (day, time) = day_and_time(&event.start, event.end.as_deref());
                         let day_time = (day, format!("{time}{}", event.zone_label()));
                         (event.title, day_time, event.location, event.notes, event.id)
    @@ -400,7 +420,7 @@
                 let days = args["days"].as_i64().unwrap_or(7).clamp(1, 60);
                 let from = stamp(now);
                 let to = stamp(now + Duration::days(days));
    -            let events: Vec<Event> = load(host_dir).into_iter().filter(|e| e.local_stamp(true) >= from && e.local_stamp(false) <= to).collect();
    +            let events: Vec<Event> = load_checked(host_dir)?.into_iter().filter(|e| e.local_stamp(true) >= from && e.local_stamp(false) <= to).collect();
                 publish_card(agenda_card_args(&events, days, now))
             }
             other => Err(format!("calendar has no method {other:?}")),
    @@ -538,3 +558,69 @@
             let _ = std::fs::remove_dir_all(&d);
         }
     }
    +
    +#[cfg(test)]
    +mod reconciliation_tests {
    +use super::*;
    +
    +fn fixture(tag: &str) -> std::path::PathBuf {
    +    let root = std::env::temp_dir().join(format!("muse-calendar-review-{tag}-{}", std::process::id()));
    +    let _ = std::fs::remove_dir_all(&root);
    +    root
    +}
    +
    +#[test]
    +fn exact_readback_reconciles_create_update_and_delete() {
    +    let root = fixture("lifecycle");
    +    let now = parse_time("2026-10-10T18:00").unwrap();
    +    let created = handle(APP, "add_event", &json!({"title":"Synthetic review", "start":"2026-10-11T15:00", "request_id":"synthetic-review"}), &root, now, None).unwrap();
    +    let by_request = handle(APP, "get_event", &json!({"request_id":"synthetic-review"}), &root, now, None).unwrap();
    +    assert_eq!(by_request["event"]["id"], created["id"]);
    +    let snapshot = by_request["event"].clone();
    +    let edit = json!({"id":created["id"],"expected":snapshot,"title":"Synthetic review", "start":"2026-10-11T16:00"});
    +    handle(APP, "update_event", &edit, &root, now, None).unwrap();
    +    assert!(handle(APP, "update_event", &edit, &root, now, None).is_err());
    +    let current = handle(APP, "get_event", &json!({"id":created["id"]}), &root, now, None).unwrap();
    +    assert_eq!(current["event"]["start"], "2026-10-11T16:00");
    +    assert_eq!(current["event"]["request_id"], "synthetic-review");
    +    handle(APP, "remove_event", &json!({"id":created["id"],"expected":current["event"]}), &root, now, None).unwrap();
    +    let absent = handle(APP, "get_event", &json!({"id":created["id"]}), &root, now, None).unwrap();
    +    assert_eq!(absent, json!({"found":false,"event":null}));
    +    assert!(handle("muse-goals", "get_event", &json!({"id":created["id"]}), &root, now, None).is_err());
    +    std::fs::remove_dir_all(root).unwrap();
    +}
    +
    +#[test]
    +fn unreadable_store_never_becomes_empty_or_overwritten() {
    +    let root = fixture("corrupt");
    +    std::fs::create_dir_all(root.join("calendar")).unwrap();
    +    let original = b"{unfinished calendar records";
    +    std::fs::write(store_path(&root), original).unwrap();
    +    let now = parse_time("2026-10-10T18:00").unwrap();
    +    for (method,args) in [
    +        ("events",json!({})), ("view",json!({})), ("get_event",json!({"id":"synthetic"})),
    +        ("add_event",json!({"title":"Synthetic", "start":"2026-10-11T15:00"})),
    +        ("update_event",json!({"id":"synthetic"})), ("remove_event",json!({"id":"synthetic"}))
    +    ] {
    +        assert!(handle(APP,method,&args,&root,now,None).is_err(), "{method}");
    +        assert_eq!(std::fs::read(store_path(&root)).unwrap(),original);
    +    }
    +    std::fs::remove_dir_all(root).unwrap();
    +}
    +
    +#[test]
    +fn exact_lookup_is_independent_of_list_cap_and_rejects_ambiguity() {
    +    let root = fixture("cap");
    +    let now = parse_time("2026-10-10T18:00").unwrap();
    +    let events: Vec<Event> = (0..201).map(|n| Event { id:format!("synthetic-{n:04}"),title:"Synthetic".into(),
    +        start:"2026-10-11T15:00".into(),end:None,location:String::new(),notes:String::new(),request_id:format!("request-{n:04}"),timezone:String::new() }).collect();
    +    save(&root,&events).unwrap();
    +    assert_eq!(handle(APP,"events",&json!({"limit":200}),&root,now,None).unwrap()["events"].as_array().unwrap().len(),200);
    +    assert_eq!(handle(APP,"get_event",&json!({"id":"synthetic-0200"}),&root,now,None).unwrap()["event"]["id"],"synthetic-0200");
    +    let mut duplicate = events.clone(); duplicate.push(events[0].clone()); save(&root,&duplicate).unwrap();
    +    assert!(handle(APP,"get_event",&json!({"request_id":"request-0000"}),&root,now,None).unwrap_err().contains("ambiguous"));
    +    assert!(handle(APP,"get_event",&json!({}),&root,now,None).is_err());
    +    std::fs::remove_dir_all(root).unwrap();
    +}
    +
    +}
    --- a/apps/calendar/host-service/src/ui.rs
    +++ b/apps/calendar/host-service/src/ui.rs
    @@ -9,7 +9,7 @@
     
     /// A trusted shell navigation request. The target must exist in this store.
     pub fn focus_event(host_dir: &Path, id: &str) -> Result<(), String> {
    -    if !load(host_dir).iter().any(|event| event.id == id) {
    +    if !load_checked(host_dir)?.iter().any(|event| event.id == id) {
             return Err("This event is no longer in Calendar.".into());
         }
         FOCUS
    @@ -53,7 +53,7 @@
     }
     
     pub fn view(host_dir: &Path, args: &Value, now: NaiveDateTime) -> Result<Value, String> {
    -    let events = load(host_dir);
    +    let events = load_checked(host_dir)?;
         let zone = events
             .iter()
             .find(|e| !e.timezone.is_empty())
    @@ -137,7 +137,7 @@
     }
     
     pub fn update(host_dir: &Path, args: &Value) -> Result<Value, String> {
    -    let mut events = load(host_dir);
    +    let mut events = load_checked(host_dir)?;
         let event = events
             .iter_mut()
             .find(|e| e.id == text(args, "id"))
    --- a/apps/calendar/bundle/tools.json
    +++ b/apps/calendar/bundle/tools.json
    @@ -44,6 +44,48 @@
           "shareable": true
         },
         {
    +      "name": "calendar.get_event",
    +      "description": "Exact durable readback by one event id or stable request_id, including authoritative absence. Refuses unreadable or ambiguous records. This is the same built-in Calendar store.",
    +      "input_schema": {
    +        "type": "object",
    +        "properties": {
    +          "id": {
    +            "type": "string",
    +            "minLength": 1,
    +            "maxLength": 64
    +          },
    +          "request_id": {
    +            "type": "string",
    +            "minLength": 1,
    +            "maxLength": 160
    +          }
    +        },
    +        "additionalProperties": false
    +      },
    +      "output_schema": {
    +        "type": "object",
    +        "properties": {
    +          "found": {
    +            "type": "boolean"
    +          },
    +          "event": {
    +            "type": [
    +              "object",
    +              "null"
    +            ]
    +          }
    +        },
    +        "required": [
    +          "found",
    +          "event"
    +        ]
    +      },
    +      "risk": "read",
    +      "background": true,
    +      "implemented_by": "host-service",
    +      "shareable": true
    +    },
    +    {
           "name": "calendar.add_event",
           "description": "Add an event to the person's calendar. start (and end) are local times like 2026-10-02T15:00, or a date like 2026-10-02 for the whole day. Answers the new event's id. For retries supply the same stable request_id and exact event fields; it reuses the saved event. Changed fields with that key are refused instead of duplicating it. This is OctoSense local storage, not Google Calendar sync. Supply timezone (IANA, e.g. America/Los_Angeles) to preserve the event wall time independently of device settings. Missing timezone uses device local time. Do not guess an unspecified end.",
           "input_schema": {
    @@ -168,9 +210,12 @@
               "event"
             ]
           },
    -      "risk": "act",
    -      "background": true,
    -      "implemented_by": "host-service"
    +      "risk": "destructive",
    +      "background": true,
    +      "implemented_by": "host-service",
    +      "shareable": true,
    +      "confirm": "host",
    +      "auto_approvable": false
         },
         {
           "name": "calendar.remove_event",
    @@ -182,10 +227,14 @@
                 "type": "string",
                 "minLength": 1,
                 "maxLength": 64
    -          }
    -        },
    -        "required": [
    -          "id"
    +          },
    +          "expected": {
    +            "type": "object"
    +          }
    +        },
    +        "required": [
    +          "id",
    +          "expected"
             ],
             "additionalProperties": false
           },
    @@ -203,7 +252,9 @@
           "risk": "destructive",
           "confirm": "host",
           "background": true,
    -      "implemented_by": "host-service"
    +      "implemented_by": "host-service",
    +      "shareable": true,
    +      "auto_approvable": false
         },
         {
           "name": "calendar.notify",
  3. 9tuore commented on Oct 10, 2026

    @9tuore
    Author

    Full Desktop follow-up: owner UI CRUD now verified; Muse Agent admission remains separate

    Fixed sources: Desktop RC2 4ccf8e068399b1da139771a9ed94cef05fa6ae60, App Hub 95e4831afca7227b640f075b252c04355bb63865. This is an isolated companion proposal, not an accepted official release.

    We built the complete Intel Desktop with the pinned Octos kernel. Frozen Host SHA-256: baf55d24c3245ca858e68b9776d7f470558d619c65edc4ce288ceb90c8a276b7. In the real built-in Calendar owner window, one specifically authorized synthetic event was created, edited without changing its ID, read back through the existing fixed-grant System calendar.events route, restored after actual process exit/restart, then deleted through the normal two-step owner UI. A second actual restart returned an empty bounded target-day list. All three owned processes exited normally; screenshots and replies are retained.

    These results do not establish Muse's own-ID Agent Relay, a model-selected tool call, native Person consent, exact-ID lookup, or authoritative deletion absence. calendar.get_event still returns not_granted for the existing System grant. Owner success does not resolve #182 or the owner-only sharing policy discussed here.

    The remaining cold-registration issue in the existing read-only diagnostic entry was isolated: Calendar declarations were checked before its owner executor was lazily loaded. The narrow optional patch calls the existing admitted Calendar lazy loader before the read-only declaration filter. It adds no System grants or caller identity, and keeps risk, schema, admission and Relay checks. Three exact Shell tests and eight real diagnostic checks passed, including write refusal and unchanged get denial. This diagnostic proposal is separate from enabling third-party Calendar access.

    Review artifacts at the exact development commit:

    Could maintainers confirm the supported route for a third-party app to obtain an explicit Calendar read/write offer and precise reconciliation, while preserving the current owner policy, user consent and trusted approval requirements? We have not bypassed those requirements or represented this companion work as upstream acceptance.

  4. 9tuore commented on Oct 13, 2026

    @9tuore
    Author

    RC6 follow-up: real third-party UI → native Calendar Relay → same-ID update/delete now exercised in a paired Host

    This continues #427; it is a local host-extension contribution, not upstream acceptance or stock App Hub compatibility. The current official owner-only sharing policy still requires an upstream design decision.

    Fixed base: Desktop desktop-v0.1.0-rc.6, SDK f9f6fea90b52182790cb2641fd0145c425af4d84, App Hub 386359153ed6377b5f047bd9ed2767a6a2ac2bc7, pinned Octos kernel b0759a57719fd35b3a2da1c5d969bc67538ed516 (2.0.3-rc.13). The full Intel Host was rebuilt with app-hub,octos-core and launched through a normal macOS application. Source executable SHA-256 before local signing: f7a189ba0a182c46798415c73f7b66ec6e49307a280eba5612ae85205e30bca3.

    The working path is a narrow structured foreground adapter, calendar_bridge.run/history, in crates/shell/src/host_tools/calendar_bridge.rs:

    • The admitted muse-goals UI sends {request_id, operation, args}. It cannot supply a claimed owner identity or arbitrary kernel RPC.
    • Authorization checks admitted-host identity, the app declaration, active account, assistant consent, suspended state, and the Relay's exact per-tool grant.
    • The host routes through its existing grant/admission/approval/executor machinery to os.calendar. The app is not relabelled as os.calendar.
    • add: calendar.add_event followed by exact calendar.get_event.
    • update: exact read → calendar.update_event with the original snapshot → exact read; preserve the same ID and original creation request ID.
    • remove: exact read → calendar.remove_event with expected snapshot → exact read returning found:false.
    • A durable app/account/request journal records the request digest and distinct tool-call receipts. Reusing a request ID with different arguments is refused. After restart, history reconciles the saved request instead of inventing a new mutation.
    • Receipts are explicitly source: host_calendar_relay, not a fabricated kernel Agent transcript. In this route the model extracts arguments; the structured adapter selects the CRUD sequence. This does not demonstrate unrestricted model-selected multi-tool execution.

    Actual GUI result on 2026-10-14, using one synthetic event:

    Operation Actual result
    Create Event saved at 2026-10-15 15:00–16:00; independent exact get returned its saved fields
    Update The same event ID changed to 16:00–17:00; title/location retained; pre-get/update/post-get were separate calls
    Delete Normal native destructive review was shown and approved through the UI; post-get returned found:false

    The same event ID was ev-1791936336411-10536678661696203558747825447691337924 throughout. Native approval was not replaced with a manufactured receipt. Raw screenshots, UI-operation timestamps, request journals, and failures are retained locally; personal profile/account data is not attached here.

    The exact-ID and fallible-read Calendar changes proposed earlier remain necessary: bounded calendar.events absence is not deletion proof, and corrupt storage must produce an error rather than be overwritten as an empty calendar. The local signed bundle is admitted only by the explicitly paired Host offer. The unchanged default Host gate still refuses these requested tools; we do not describe that as upstream admission success.

    A small reproducible protocol for callers is:

    {"request_id":"example-create-01","operation":"add","args":{"request_id":"example-create-01","title":"Review","start":"2026-10-15T15:00","end":"2026-10-15T16:00","timezone":"Asia/Shanghai","location":"Online","notes":""}}

    Then reconcile that request using calendar_bridge.history, and bind subsequent edits to the exact returned event. Never infer mutation success from model prose or a card merely appearing.

    Would upstream prefer formalizing a contained-app structured Calendar API, or expanding the existing Agent offer/Relay contract to support this exact reconciliation without an adapter? This report contributes the actual working boundary and failure handling for that decision. Separately, a scoped Shell Dock/work-area fix has been submitted because maximizing a contained app obscured the bottom controls during this same real acceptance run.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions