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 }, withObserve,FetchInterface, andCallOperationvariants 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
idand the matchingResponsevariant containingResult<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.
ClientLimitsstill 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()andFetchInterfaceRequest::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-localInvalidResponse, 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;/ais 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 isInvalidRequest, but a valid reference outside the target's scope isInterfaceMismatch, withNotFoundretaining 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, ¤t_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, ®istry, &reference), which resolves
the visible scope Object with with_object and uses its attached current binding.
Low-level registry.fetch_interface(&reference, ¤t_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.