Files
wip-rs/MIGRATION.md

13 KiB

Structured Interface reference and scope publication migration

Transport-independent Client API

The default wip-client feature set still exposes the HTTP-compatible wip_client::Client and PreparedRequest. Its constructor takes state and wire limits, sessions canonicalize HTTP endpoints, and complete consumes an HTTP response. Existing TUI and native HTTP consumers need no source migration; the wire contract is unchanged.

For an in-process Host (including Client and Host in one WASM module), disable default features and use wip_client::direct::{Client, Request, Response}:

wip-client = { version = "0.2.0", default-features = false }
  • direct::Client::new(ClientLimits) takes no wire limits.
  • open_session(worldspace_identity, SecurityContext) accepts an opaque identity, not a URL. Issue a separate identity per Worldspace view/Host lifetime and a separate security identity whenever authorization context changes. Direct and canonical HTTP session identities cannot alias each other's cache.
  • Preparation methods retain their names and arguments. Their result is direct::PreparedRequest { id, request: Request }, with Observe, FetchInterface, and CallOperation variants containing Protocol request DTOs.
  • Route that exact DTO to the session's Host/security context; mark calls dispatched before invoking the Host. Complete with the original id and the matching Response variant containing Result<Protocol DTO, ProtocolError>. No HTTP request, response, JSON codec, or artificial endpoint is needed.
  • Direct responses undergo the same semantic validation and state transitions as HTTP responses. Wrong interactions and invalid results are rejected; invalid call responses retain an unknown execution outcome. Retrievals may be explicitly refreshed, but operations are never automatically retried.
  • ClientLimits still bounds cache, sessions, in-flight requests, and history. HTTP status/envelope checks and byte/JSON nesting limits belong to the optional HTTP adapter. An embedding application owns DTO allocation/execution budgets.

The direct module API documentation contains a runnable typed example. Run cargo test -p wip-client --no-default-features to exercise the core without an HTTP dependency; ordinary cargo test -p wip-client also runs unchanged HTTP regressions and direct/HTTP state parity tests.

The 0.2.0 API/wire change follows wip-reference canonical Core Model, Protocol and WIP over HTTP at 6086f3c5ef10aa1464ce9c824750d7f667217bfd. It replaces the earlier opaque Interface reference contract; HTTP routes and error codes have not changed. Peers must upgrade together: the codec does not accept legacy strings or display shorthand as Protocol references.

Rust API

Object.interfaces is now Vec<InterfaceReference>. FetchInterfaceRequest.interface, FetchInterfaceResponse.interface, and InterfaceTarget.reference all use the same record:

use wip_protocol::{FetchInterfaceRequest, InterfaceReference, InterfaceTarget};

let reference = InterfaceReference {
    scope: "/github".into(),
    name: "issue.read".into(),
};
reference.validate()?; // reference shape, no target Object path needed
let fetch = FetchInterfaceRequest { interface: reference.clone() };
let target = InterfaceTarget {
    reference,
    scope_ref: Some("opaque-scope-object-ref".into()),
    validator: None,
};
# Ok::<(), wip_protocol::ValidationError>(())

Construct scope and name explicitly from your Worldspace publication context. There is no implicit conversion from a string, delimiter parser, normalization, case folding, or URL decoding. A name may contain /, ., ::, #, or Unicode; only an empty name is invalid. Scope must be a canonical absolute Worldspace path: /, or nonempty slash-separated segments other than . and .., with no empty segments or trailing slash. %2F is ordinary field text, not an escape for a Worldspace path separator.

Equality, hashing and ordering operate on both fields, independent of member order/JSON escape spelling. Use the record itself as a membership/cache key; identical names in different scopes are different references. The descriptor still contains only format, documentation, types and operations, not identity.

FetchInterfaceResponse and InterfaceTarget additionally require the Rust field scope_ref: Option<String> in struct literals. Use None when omitted, not an invented identity. This is an opaque scope Object ref observation/call precondition, not a third Interface identity component or a validator. Host enforces scope publication lifetimes and optional scope-ref identity inside the provider boundary. Client caches keep the complete structured reference and optional fetched scope binding; confirmed scope deletion or ref replacement invalidates bound observations, while local eviction is not deletion evidence. Do not mistake registry artifact retention for publication.

Validation boundaries

  • InterfaceReference::validate() and FetchInterfaceRequest::validate() check reference shape independently of a target Object path.
  • Object::validate() checks shape and duplicate (scope, name) pairs; Object::validate_at_path() additionally checks placement, including scope. ObserveRequest::validate_response() checks every derived node path, including the depth boundary. Invalid success responses are Client-local InvalidResponse, not Host-returned Protocol errors.
  • InterfaceReference::validate_for_path(path) checks ancestor-or-self scope compatibility by path segments. Root and self scope are valid; /a is not an ancestor of /ab. This has no indexability dependency.
  • CallOperationRequest::validate() checks descriptor-independent shape. validate_with() adds operation/argument checks but does not replace Host publication lookup. Host interface selection checks scope compatibility and exact membership after target lookup: invalid reference shape is InvalidRequest, but a valid reference outside the target's scope is InterfaceMismatch, with NotFound retaining precedence.

HTTP wire

All four reference positions use an exact object with required string fields:

{"scope":"/github","name":"issue.read"}

For example:

{
  "interface": {"scope":"/github","name":"issue.read"},
  "scope_ref": "opaque-scope-object-ref",
  "descriptor": {"format":"wip-interface/1","types":[],"operations":[]}
}

A call uses:

{
  "target": {"path":"/github/issues/123"},
  "interface": {
    "reference": {"scope":"/github","name":"issue.read"},
    "scope_ref": "opaque-scope-object-ref"
  },
  "operation":"refresh",
  "arguments":{}
}

Unknown/duplicate members, missing fields, null fields, relative scopes, empty names, legacy "interface-v1" and shorthand "/github::issue.read" strings are rejected. Field order and JSON escaping do not affect decoded identity. scope_ref is a normal opaque string, not Base64; omission is allowed but explicit null and nonstrings are invalid. An empty string is a valid opaque ref. Validators remain canonical padded standard Base64.

Descriptor-independent HTTP call metadata decoding enforces reference shape and the same strict JSON rules without turning scope compatibility into early InvalidRequest. Normal descriptor-bound encode/decode uses the same reference codec. Request violations retain the existing 400 invalid_request boundary; malformed success responses retain Client-local InvalidResponse. Standard POST v1/observe, v1/fetch_interface, v1/call_operation, error-code/status mapping and Transport failure classification are unchanged.

Host registration and provider migration

Registration now requires an explicit canonical Scope and local name:

let mut registry = wip_host::InterfaceRegistry::new();
let scope = wip_host::Scope::new("/service")?;
let api = registry.register(&scope, "catalog", CatalogDefinition, None)?;

// INSIDE the backend transaction/lock/stable-handle boundary:
let current = wip_host::ScopeObservation::new(&scope, &current_scope_object);
let publication = wip_host::PublishedObject::new(current_target_object)
    .expose(&api)?
    .bind_scope(current);

Collision keys are exact (scope, name) coordinates, not Rust Definition TypeId. The same Definition can be registered in different scopes or under different names. Root, self and ancestor scopes are valid, but ancestor compatibility and explicit target membership remain independent checks. Descriptors still have no publication identity fields.

A Scope carries a private publication lifetime, not a generated Protocol ref. Clone it only while the same scope Object remains published: ordinary Object state/validator updates do not retire it. Deletion, replacement or movement requires retiring the old lifetime with registry.remove_scope(&scope) and creating a fresh Scope before republication, even at the same path or ref. Retained registration tokens cannot fetch or start calls after retirement. Ref-less replacement cannot be tracked by a Client identity precondition; refs and scope_ref remain optional, not forcibly generated.

ScopeObservation::new(&scope, &object) is a provider attestation that this lifetime currently belongs to the Object at the scope path. Construct and attach it with PublishedObject::bind_scope inside the same coherent boundary as target resolution, membership, Descriptor/validator selection, implementation and backend handles, through dispatch and commit/rollback. A matching ref or an independent scope prelookup is not sufficient. The scope Object need not expose any interfaces. Providers own atomicity and external-writer limitations; the runtime checks the attached lifetime and optional scope_ref, and validates both arguments and results against the same frozen Descriptor. A supplied scope_ref mismatch, including a missing current ref, is InterfaceMismatch at membership precedence, after target existence and a supplied Object validator mismatch. Omission applies no scope identity precondition.

Artifact-only registry.fetch_interface(reference) is removed. Prefer ObjectProvider::fetch_interface(&provider, &registry, &reference), which resolves the visible scope Object with with_object and uses its attached current binding. Low-level registry.fetch_interface(&reference, &current_observation) requires the same coherent current attestation, not a cached snapshot. Fetch returns Descriptor, Interface validator and optional scope Object ref together; Clients need not additionally fetch an ancestor Object. registry.scopes() may repeat a handle for several names; do not treat it as a unique-scope iterator.

PublishedObject::protocol_object() is a path-free composition projection, not publication authority. For current observations use ObjectProvider::observe_object or publication.protocol_object_at(path) inside the coherent provider boundary; the latter also validates compatible live scope associations and response shape.

Downstream status

The filesystem default registers local names file.read, symlink.inspect and file.write at its deployment-pinned root Scope, displayed as /::file.read etc. External compositions choose explicit Scope/local names and implement Composition::scope to attest a non-root lifetime from the exact refreshed backend entry in its connected lineage. The engine never independently resolves a scope path during dispatch. Default composition remains root-scoped; exposing non-root registrations requires the application's current lifetime attestation, not merely an artifact or matching ref/validator. All bindings are formed inside backend execution, with current principal lineage visibility and publication metadata. Retire/recreate Scope handles on deletion, replacement or movement; a pinned leaf alone is not evidence that a self-scope Object remains published at that path. The Linux deployment root remains pinned across textual root rename/replacement, and its ref-less default publication continues to omit scope_ref.

The public API example self-scopes catalog, audit and diagnostics at /article-service, fetches through its provider, and constructs call metadata from the fetched structured reference and scope_ref. External-style conformance tests also cross real TCP HTTP for root/self-scopes, same names in different scopes, optional refs, stale refs, deletion/republication and validator precedence.

The TUI stores structured references in cache keys, selections, actions, editors and in-flight subjects. Normal screens reuse the canonical absolute scoped formatter (/scope::name#operation, with JSON component quoting when needed). Labels are never parsed to reconstruct identity, and scope_ref/validators are not normal-screen reference labels. Client invalidation is synchronized back into UI observations before dispatch; re-observation is not automatic execution. Typed result entries are still fetched on selection, not all at once, and do not create inferred indexable edges. Interface authoring/macros remain descriptor-only.