Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions .proofs/2026-06-18-pawfs-direct-key-write-hotfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# PawFS direct-key write hotfix proof

Date: 2026-06-18

## Scope

Live Katagami regeneration reached `temper.write`, then failed on PawFS directory
resolution because Monty looked up directories with a broad OData collection
filter:

`/tdata/Directories?$filter=Path eq ... and WorkspaceId eq ...`

Production rejected that shape with `413 QueryTooLarge` once the directory table
was large enough. This patch keeps the existing PawFS entity model and changes
Monty write resolution to derive deterministic directory/file IDs from
`(workspace_id, normalized_path)`, then read by key before creating missing
entities.

## ADR judgement

No new ADR. This is a scoped implementation repair under the existing PawFS
direct-access model, not a new entity type, workflow, storage model, trigger,
policy boundary, or agent capability surface.

## Red

Added `monty_pawfs_write_path_uses_direct_keys_not_broad_path_filters` in
`crates/temperpaw/tests/paw_fs_hot_path.rs`.

Initial result before implementation:

```text
cargo test -p temperpaw --test paw_fs_hot_path monty_pawfs_write_path_uses_direct_keys_not_broad_path_filters
test monty_pawfs_write_path_uses_direct_keys_not_broad_path_filters ... FAILED
Monty PawFS writes should derive deterministic directory ids
```

## Green

Local verification after implementation:

```text
cargo test -p temperpaw --test paw_fs_hot_path
test result: ok. 14 passed; 0 failed

cargo test --manifest-path os-apps/paw-agent/wasm/monty_repl/Cargo.toml
test result: ok. 73 passed; 0 failed

./os-apps/paw-agent/wasm/build.sh
All WASM modules built, including rebuilt monty_repl.
```

## Live E2E

Pending. Next step is to publish the new `temperpaw/paw-agent` Genesis ref,
update/reinstall OpenPaw production, and rerun the live Katagami regeneration
job that reproduced the PawFS write-path 413.
26 changes: 26 additions & 0 deletions crates/temperpaw/tests/paw_fs_hot_path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,32 @@ fn monty_exposes_write_many_for_artifact_sets() {
);
}

#[test]
fn monty_pawfs_write_path_uses_direct_keys_not_broad_path_filters() {
let source = repo_file("os-apps/paw-agent/wasm/monty_repl/src/entity_ops.rs");

assert!(
source.contains("pawfs_directory_id"),
"Monty PawFS writes should derive deterministic directory ids"
);
assert!(
source.contains("pawfs_file_id"),
"Monty PawFS writes should derive deterministic file ids"
);
assert!(
source.contains("/tdata/Directories('{directory_id}')"),
"Monty PawFS writes should read directories by key"
);
assert!(
source.contains("/tdata/Files('{file_id}')"),
"Monty PawFS writes should read files by key"
);
assert!(
!source.contains("Path eq '{}' and WorkspaceId eq '{}' and Status ne 'Archived'"),
"Monty PawFS writes must not use broad Path+Workspace collection filters that hit bounded OData candidate limits"
);
}

#[test]
fn default_agent_tool_allowlists_include_write_many() {
for path in [
Expand Down
30 changes: 30 additions & 0 deletions crates/temperpaw/tests/session_turn_architecture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,10 @@ fn session_entry_readbacks_stay_within_bounded_query_budget() {
let root = repo_root();
let helpers = fs::read_to_string(root.join("os-apps/paw-agent/wasm/wasm-helpers/src/lib.rs"))
.expect("wasm helpers source should exist");
let context_preparer_wasm =
fs::read(root.join("os-apps/paw-agent/wasm/context_preparer/context_preparer.wasm"))
.expect("context_preparer.wasm should be checked in");
let context_preparer_wasm = String::from_utf8_lossy(&context_preparer_wasm);
let route_message =
fs::read_to_string(root.join("os-apps/paw-channels/wasm/route_message/src/lib.rs"))
.expect("route_message source should exist");
Expand All @@ -375,6 +379,32 @@ fn session_entry_readbacks_stay_within_bounded_query_budget() {
helpers.contains("session_entries_verify_urls"),
"batched SessionEntry readback should use one bounded per-entry URL per expected entry"
);
assert!(
helpers.contains("read_session_entries_from_leaf"),
"SessionEntry history reads should prefer bounded direct EntryId parent-chain reads from session_leaf_id"
);
assert!(
helpers.contains("ParentEntryId"),
"SessionEntry parent-chain reads must follow ParentEntryId instead of session-wide scans"
);
assert!(
context_preparer_wasm.contains("SessionEntry direct lookup failed")
&& context_preparer_wasm.contains("SessionEntry parent-chain read")
&& context_preparer_wasm.contains("SessionEntry virtual first-turn leaf"),
"built context_preparer.wasm must include the bounded leaf parent-chain reader"
);
assert!(
helpers.contains("$orderby=Sequence%20asc"),
"SessionEntry history reads should use deterministic keyset ordering"
);
assert!(
helpers.contains("Sequence%20ge%20{next_sequence}"),
"SessionEntry history reads should continue by Sequence instead of broad skip scans"
);
assert!(
!helpers.contains("&$skip="),
"SessionEntry history reads must not use $skip because production bounded OData can reject high-candidate scans"
);
assert!(
route_message.contains("session_leaf_id is missing; starting clean continuation"),
"route_message should start cleanly instead of broad-scanning when the prior leaf hint is missing"
Expand Down
111 changes: 90 additions & 21 deletions os-apps/paw-agent/wasm/monty_repl/src/entity_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2176,6 +2176,30 @@ fn http_get(
.map_err(|e| format!("failed to parse response from {path}: {e}"))
}

fn http_get_optional(
ctx: &Context,
api_url: &str,
_tenant: &str,
_principal_id: &str,
path: &str,
) -> Result<Option<Value>, String> {
let url = format!("{api_url}{path}");
let headers = internal_headers();
let resp = ctx.http_call("GET", &url, &headers, "")?;
if let Some(denial) = dispatch::check_cedar_denial(resp.status, &resp.body) {
return Err(denial);
}
if resp.status == 404 {
return Ok(None);
}
if resp.status >= 400 {
return Err(format!("HTTP GET {path}: {} {}", resp.status, resp.body));
}
serde_json::from_str(&resp.body)
.map(Some)
.map_err(|e| format!("failed to parse response from {path}: {e}"))
}

fn http_post(
ctx: &Context,
api_url: &str,
Expand Down Expand Up @@ -2405,12 +2429,25 @@ fn pawfs_path_segments(path: &str) -> Vec<&str> {
.collect()
}

fn pawfs_filter_path_and_workspace(path: &str, ws_id: &str) -> String {
format!(
"Path eq '{}' and WorkspaceId eq '{}' and Status ne 'Archived'",
escape_odata_string(path),
escape_odata_string(ws_id)
)
fn pawfs_stable_hash(parts: &[&str]) -> u64 {
let mut hash = 0xcbf29ce484222325_u64;
for part in parts {
for byte in part.as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x100000001b3);
}
hash ^= 0xff;
hash = hash.wrapping_mul(0x100000001b3);
}
hash
}

fn pawfs_directory_id(ws_id: &str, path: &str) -> String {
format!("dir-{:016x}", pawfs_stable_hash(&[ws_id, path]))
}

fn pawfs_file_id(ws_id: &str, path: &str) -> String {
format!("fl-{:016x}", pawfs_stable_hash(&[ws_id, path]))
}

fn pawfs_filter_parent_and_workspace(parent_id: &str, ws_id: &str) -> String {
Expand All @@ -2421,8 +2458,11 @@ fn pawfs_filter_parent_and_workspace(parent_id: &str, ws_id: &str) -> String {
)
}

fn pawfs_first_entity(resp: &Value) -> Option<&Value> {
resp.get("value").and_then(Value::as_array)?.first()
fn pawfs_entity_is_archived(value: &Value) -> bool {
matches!(
entity_field_str_any(value, &["Status", "status"]),
Some("Archived")
)
}

fn pawfs_entity_id(value: &Value) -> Option<&str> {
Expand Down Expand Up @@ -2514,16 +2554,22 @@ fn find_pawfs_directory(
raw_path: &str,
) -> Result<Option<PawFsDirectory>, String> {
let path = pawfs_normalize_path(raw_path)?;
let filter = urlenc(&pawfs_filter_path_and_workspace(&path, ws_id));
let directory_id = pawfs_directory_id(ws_id, &path);
let eid = ctx_entity_id(ctx);
let resp = http_get(
let Some(resp) = http_get_optional(
ctx,
api_url,
tenant,
eid,
&format!("/tdata/Directories?$filter={filter}"),
)?;
Ok(pawfs_first_entity(&resp).and_then(pawfs_directory_from_value))
&format!("/tdata/Directories('{directory_id}')"),
)?
else {
return Ok(None);
};
if pawfs_entity_is_archived(&resp) {
return Ok(None);
}
Ok(pawfs_directory_from_value(&resp))
}

fn find_pawfs_file(
Expand All @@ -2534,16 +2580,22 @@ fn find_pawfs_file(
raw_path: &str,
) -> Result<Option<PawFsFile>, String> {
let path = pawfs_normalize_path(raw_path)?;
let filter = urlenc(&pawfs_filter_path_and_workspace(&path, ws_id));
let file_id = pawfs_file_id(ws_id, &path);
let eid = ctx_entity_id(ctx);
let resp = http_get(
let Some(resp) = http_get_optional(
ctx,
api_url,
tenant,
eid,
&format!("/tdata/Files?$filter={filter}"),
)?;
Ok(pawfs_first_entity(&resp).and_then(pawfs_file_from_value))
&format!("/tdata/Files('{file_id}')"),
)?
else {
return Ok(None);
};
if pawfs_entity_is_archived(&resp) {
return Ok(None);
}
Ok(pawfs_file_from_value(&resp))
}

fn create_pawfs_directory(
Expand All @@ -2557,6 +2609,7 @@ fn create_pawfs_directory(
parent_id: Option<&str>,
) -> Result<PawFsDirectory, String> {
let mut body = json!({
"Id": pawfs_directory_id(ws_id, path),
"Name": name,
"Path": path,
"WorkspaceId": ws_id,
Expand Down Expand Up @@ -2665,6 +2718,7 @@ fn ensure_pawfs_file(
let (dir_path, filename) = pawfs_parse_file_path(&normalized)?;
let directory = ensure_pawfs_directory(ctx, api_url, tenant, principal_id, ws_id, dir_path)?;
let body = json!({
"Id": pawfs_file_id(ws_id, &normalized),
"Name": filename,
"Path": normalized,
"DirectoryId": directory.id.clone(),
Expand Down Expand Up @@ -2988,11 +3042,26 @@ mod tests {
}

#[test]
fn pawfs_filter_escapes_odata_string_literals() {
fn pawfs_deterministic_ids_are_workspace_and_path_scoped() {
let ws_one = "ws-1";
let ws_two = "ws-2";
let path = pawfs_normalize_path("//notes///readme.md").unwrap();

assert_eq!(
pawfs_directory_id(ws_one, &path),
pawfs_directory_id(ws_one, "/notes/readme.md")
);
assert_ne!(
pawfs_directory_id(ws_one, &path),
pawfs_directory_id(ws_two, &path)
);
assert_eq!(
pawfs_filter_path_and_workspace("/notes/we're-here.md", "ws'1"),
"Path eq '/notes/we''re-here.md' and WorkspaceId eq 'ws''1' and Status ne 'Archived'"
pawfs_file_id(ws_one, &path),
pawfs_file_id(ws_one, "/notes/readme.md")
);
assert_ne!(pawfs_directory_id(ws_one, &path), pawfs_file_id(ws_one, &path));
assert!(pawfs_directory_id(ws_one, &path).starts_with("dir-"));
assert!(pawfs_file_id(ws_one, &path).starts_with("fl-"));
}

#[test]
Expand Down
Loading