Skip to content
Merged
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
18 changes: 18 additions & 0 deletions .claude/skills/escurel-platform/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,24 @@ The skill version tracks the consumer-facing contract, not the Escurel
binary version. The Escurel repo's checked-out git ref is the true version
pin (see `SKILL.md` → "How this skill is installed").

## 0.6.29 — the typed Rust client catches up with the wire guards

- `escurel-client` / `escurel-types` contract parity
(`references/05-consume-from-rust.md`): `UpdatePageRequest` now
carries the CAS/approve guards (`base_version`, `require_exact_base`,
`base_sha256` — `Some("")` = approve-create — and the `provenance`
passthrough); `UpdatePageResponse` gained `auto_merged` +
`head_version`/`head_sha256`/`head_content`; `ExpandResponse` gained
`version` + `content_sha256`, so the full read→hash→guarded-write
loop is typed end to end. Absent guard = unguarded, byte-identical to
the old client's wire traffic.
- `ListInstancesRequest.cursor` + client plumbing: `list_instances`
now resumes from `next_cursor` (only its absence means done).
- The client stopped dropping `as_of`/`scenario` on
`expand`/`neighbours`/`list_instances` (and `scenario` on `resolve`)
— they were on the typed requests all along but never reached the
wire, silently returning current/base state.

## 0.6.28 — chat cursor errors are typed; reader event tools over the lake

- `list_messages` with an undecodable `cursor` now answers
Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/escurel-platform/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.6.0
0.6.27
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ client.list_instances(ListInstancesRequest { skill: "customer".into(), ..Default
// provenance_path(...) still exists but calls provenance_ancestry with
// `to_page` (ProvenanceAncestryRequest gained that field) under the hood.
client.provenance_report(ProvenanceReportRequest { kind: "drift".into(), ..Default::default() }).await?;
client.update_page(UpdatePageRequest { page_id, content }).await?;
client.update_page(UpdatePageRequest { page_id, content, ..Default::default() }).await?;
// Chat history (M-Chat, issue #63): append-mostly log keyed by an
// opaque chat_group_id. See `references/02` §Chat tools.
client.append_message(AppendMessageRequest {
Expand All @@ -69,6 +69,38 @@ client.list_messages(ListMessagesRequest {
}).await?;
```

The read methods forward the optional-with-meaning wire fields: `as_of`
(time-travel cut) and `scenario` (overlay) on
`expand`/`neighbours`/`list_instances`/`search` (+ `scenario` on
`resolve`) are sent when non-empty — empty = current base state.
`list_instances` paginates: pass `cursor` from the previous response's
`next_cursor`; **only an absent `next_cursor` means done** (a string
always means more rows, even on a short page).

### Guarded writes (the read→hash→approve loop)

`expand` (plain reads only) returns `content_sha256` — the hash of the
stored markdown bytes — and, on a live-CRDT gateway, `version`.
`UpdatePageRequest` carries the matching guards; all default to absent =
unguarded, so plain upserts are unchanged:

```rust
let read = client.expand(ExpandRequest { page_id: page_id.clone(), ..Default::default() }).await?;
let resp = client.update_page(UpdatePageRequest {
page_id,
content: redraft,
base_sha256: read.content_sha256, // content-hash CAS (#354): works on EVERY gateway
// base_version: read.version, // version CAS (#246): live-CRDT only; stale → auto-merge
// require_exact_base: true, // strict: stale base → conflict, never a merge (approvals)
// base_sha256: Some(String::new()), // Some("") = approve-create ("I expect no page yet")
..Default::default()
}).await?;
if !resp.ok && resp.issues.iter().any(|i| i.code == "conflict") {
// resp.head_sha256 / resp.head_content / resp.head_version: re-diff
// against head and retry. resp.auto_merged reports a landed merge.
}
```

Field names follow the wire contract (`q`/`k`, not `query`/`top_k`);
JSON-bearing fields (`frontmatter`, `rows`, `params`) are typed
`serde_json::Value`s — real JSON, not encoded strings. See `crates/escurel-types/src/` and
Expand Down
6 changes: 5 additions & 1 deletion crates/escurel-cli/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -561,7 +561,11 @@ async fn validate(client: &Client, page_id: String) -> Result<Value> {
async fn update_page(client: &Client, page_id: String) -> Result<Value> {
let content = read_stdin("page body")?;
let resp = client
.update_page(UpdatePageRequest { page_id, content })
.update_page(UpdatePageRequest {
page_id,
content,
..Default::default()
})
.await?;
Ok(json!({
"ok": resp.ok,
Expand Down
2 changes: 2 additions & 0 deletions crates/escurel-cli/src/workflow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ async fn invoke(client: &Client, a: RunArgs) -> Result<Value> {
.update_page(UpdatePageRequest {
page_id: page.clone(),
content: board_markdown(&run_id, &a.skill, "running"),
..Default::default()
})
.await
.context("create the run board")?;
Expand Down Expand Up @@ -192,6 +193,7 @@ async fn stop(client: &Client, run_id: &str) -> Result<Value> {
.update_page(UpdatePageRequest {
page_id: page.clone(),
content: board_markdown(run_id, &wf_skill, "stopped"),
..Default::default()
})
.await
.context("mark the run stopped")?;
Expand Down
69 changes: 60 additions & 9 deletions crates/escurel-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,12 +166,18 @@ impl Client {

/// Parse a `[[wikilink]]` and look up its target page.
pub async fn resolve(&self, req: ResolveRequest) -> Result<ResolveResponse, Error> {
self.transport
.call_typed("resolve", json!({ "wikilink": req.wikilink }))
.await
let mut args = json!({ "wikilink": req.wikilink });
if !req.scenario.is_empty() {
args["scenario"] = json!(req.scenario);
}
self.transport.call_typed("resolve", args).await
}

/// Fetch a page's frontmatter, body, and outbound wikilinks.
///
/// A plain read (no `as_of`/`scenario`) also returns the guard pair
/// for the read→hash→guarded-write loop: `content_sha256` (always,
/// on a server ≥ #408) and `version` (live-CRDT gateways).
pub async fn expand(&self, req: ExpandRequest) -> Result<ExpandResponse, Error> {
let mut args = json!({ "page_id": req.page_id });
if !req.anchor.is_empty() {
Expand All @@ -180,6 +186,14 @@ impl Client {
if !req.version.is_empty() {
args["version"] = json!(req.version);
}
// Time-travel cut and scenario overlay — optional-with-meaning:
// omitting them silently read the current base state.
if !req.as_of.is_empty() {
args["as_of"] = json!(req.as_of);
}
if !req.scenario.is_empty() {
args["scenario"] = json!(req.scenario);
}
if req.full {
args["full"] = json!(true);
}
Expand All @@ -195,6 +209,12 @@ impl Client {
if !req.link_skill.is_empty() {
args["link_skill"] = json!(req.link_skill);
}
if !req.as_of.is_empty() {
args["as_of"] = json!(req.as_of);
}
if !req.scenario.is_empty() {
args["scenario"] = json!(req.scenario);
}
self.transport.call_typed("neighbours", args).await
}

Expand Down Expand Up @@ -275,6 +295,19 @@ impl Client {
args["frontmatter_key"] = json!(req.frontmatter_key);
args["frontmatter_value"] = json!(req.frontmatter_value);
}
if !req.as_of.is_empty() {
args["as_of"] = json!(req.as_of);
}
if !req.scenario.is_empty() {
args["scenario"] = json!(req.scenario);
}
// Resume cursor from the previous response's `next_cursor`.
// Only an absent/null `next_cursor` means done — a string always
// means more rows (ACL filtering may shorten a page below
// `limit` with rows still to come).
if !req.cursor.is_empty() {
args["cursor"] = json!(req.cursor);
}
self.transport.call_typed("list_instances", args).await
}

Expand Down Expand Up @@ -309,13 +342,31 @@ impl Client {
}

/// Upsert a markdown page (the public write path).
///
/// The optional guards on [`UpdatePageRequest`] make this the write
/// half of the read→hash→guarded-write loop: `base_version` (#246)
/// is the optimistic-concurrency CAS with CRDT auto-merge,
/// `require_exact_base` makes it strict (approvals), and
/// `base_sha256` (#354) is the content-hash CAS that works on every
/// gateway — `Some("")` approves a create. All default to absent =
/// unguarded, so existing callers are unchanged.
pub async fn update_page(&self, req: UpdatePageRequest) -> Result<UpdatePageResponse, Error> {
self.transport
.call_typed(
"update_page",
json!({ "page_id": req.page_id, "content": req.content }),
)
.await
let mut args = json!({ "page_id": req.page_id, "content": req.content });
if let Some(v) = &req.base_version {
args["base_version"] = json!(v);
}
if req.require_exact_base {
args["require_exact_base"] = json!(true);
}
// `Some("")` is the approve-create sentinel and MUST reach the
// wire as the explicit empty string; only `None` is omitted.
if let Some(h) = &req.base_sha256 {
args["base_sha256"] = json!(h);
}
if let Some(p) = &req.provenance {
args["provenance"] = p.clone();
}
self.transport.call_typed("update_page", args).await
}

/// Soft-delete (archive) a markdown page (#300). Retracts it from
Expand Down
1 change: 1 addition & 0 deletions crates/escurel-client/tests/client_roundtrip.rs
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ name: Globex\n\
.update_page(UpdatePageRequest {
page_id: "markdown/instances/customer/globex.md".to_owned(),
content: body.to_owned(),
..Default::default()
})
.await
.unwrap();
Expand Down
Loading
Loading