Files
alknet/tasks/call/registry/service-discovery.md

7.1 KiB

id, name, status, depends_on, scope, risk, impact, level
id name status depends_on scope risk impact level
call/registry/service-discovery Implement services/list and services/schema built-in operations completed
call/registry/handler-registration
narrow low isolated implementation

Description

Implement the two built-in service discovery operations in src/registry/discovery.rs. These are read-only operations that expose what the node offers.

Operations

Operation name Display path Type Description
services/list /services/list Query List registered operation names and metadata
services/schema /services/schema Query Get the OperationSpec for a specific operation

services/list

Returns External operations only. Internal operations are not part of the wire-facing API surface — they're implementation details of composition. A remote client cannot enumerate the internal call tree (ADR-015).

{
  "operations": [
    { "name": "fs/readFile", "namespace": "fs", "op_type": "query" },
    { "name": "agent/chat", "namespace": "agent", "op_type": "subscription" },
    { "name": "events/subscribe", "namespace": "events", "op_type": "subscription" }
  ]
}

The handler queries the registry's list_operations() (which returns External specs only) and serializes to the above format.

services/schema

Accepts { "name": "fs/readFile" } (no leading slash — registry form, same as OperationSpec.name) and returns the full OperationSpec including input/output JSON Schemas and declared error_schemas (ADR-023).

The CallAdapter normalizes the leading slash from wire operationIds before lookup, so services/schema accepts both fs/readFile and /fs/readFile.

This enables client code generation: a client reading the schema can produce typed error enums instead of generic error handling.

Registration

These are registered as Local provenance with empty composition authority, empty scoped env, and empty capabilities (they don't compose, don't need credentials):

.with_local(services_list_spec(), Arc::new(services_list_handler),
            CompositionAuthority::none(), ScopedOperationEnv::empty(), Capabilities::new())
.with_local(services_schema_spec(), Arc::new(schema_handler),
            CompositionAuthority::none(), ScopedOperationEnv::empty(), Capabilities::new())

Specs

fn services_list_spec() -> OperationSpec {
    OperationSpec {
        name: "services/list".into(),
        namespace: "services".into(),
        op_type: OperationType::Query,
        visibility: Visibility::External,
        input_schema: json!({}),  // no input
        output_schema: json!({
            "type": "object",
            "properties": {
                "operations": {
                    "type": "array",
                    "items": {
                        "type": "object",
                        "properties": {
                            "name": { "type": "string" },
                            "namespace": { "type": "string" },
                            "op_type": { "type": "string", "enum": ["query", "mutation", "subscription"] }
                        }
                    }
                }
            }
        }),
        error_schemas: vec![],
        access_control: AccessControl::default(),  // no restrictions — callable by all
    }
}

fn services_schema_spec() -> OperationSpec {
    OperationSpec {
        name: "services/schema".into(),
        namespace: "services".into(),
        op_type: OperationType::Query,
        visibility: Visibility::External,
        input_schema: json!({
            "type": "object",
            "properties": { "name": { "type": "string" } },
            "required": ["name"]
        }),
        output_schema: json!({ /* full OperationSpec schema */ }),
        error_schemas: vec![],
        access_control: AccessControl::default(),
    }
}

Handlers

The handlers need access to the registry. Since handlers are Arc<dyn Fn>, the registry reference is captured in the closure. Use Arc<OperationRegistry> cloned into the closure.

fn services_list_handler(registry: Arc<OperationRegistry>) -> Handler {
    Arc::new(move |input: Value, ctx: OperationContext| {
        let registry = registry.clone();
        Box::pin(async move {
            let ops: Vec<_> = registry.list_operations()
                .into_iter()
                .filter(|s| s.visibility == Visibility::External)
                .map(|s| json!({
                    "name": s.name,
                    "namespace": s.namespace,
                    "op_type": match s.op_type {
                        OperationType::Query => "query",
                        OperationType::Mutation => "mutation",
                        OperationType::Subscription => "subscription",
                    }
                }))
                .collect();
            ResponseEnvelope::ok(ctx.request_id, json!({ "operations": ops }))
        })
    })
}

Acceptance Criteria

  • services/list spec with correct fields (Query, External, no input, output schema)
  • services/schema spec with correct fields (Query, External, name input, full spec output)
  • services/list handler returns External operations only (Internal excluded)
  • services/list output format matches spec (operations array with name, namespace, op_type)
  • services/schema handler accepts name with or without leading slash
  • services/schema returns full OperationSpec (input_schema, output_schema, error_schemas)
  • services/schema returns NOT_FOUND for unknown operation name
  • Both registered as Local provenance, empty authority/env/caps
  • Both have empty AccessControl (callable by all, including unauthenticated)
  • Unit test: services/list returns only External ops
  • Unit test: services/schema returns spec for known op
  • Unit test: services/schema returns NOT_FOUND for unknown op
  • Unit test: services/schema accepts both "fs/readFile" and "/fs/readFile"
  • cargo test -p alknet-call succeeds
  • cargo clippy -p alknet-call succeeds with no warnings

References

  • docs/architecture/crates/call/operation-registry.md — Service Discovery section
  • docs/architecture/decisions/015-privilege-model-and-authority-context.md — ADR-015 (Internal not in services/list)

Notes

services/list returns External ops only — Internal ops are implementation details of composition and must not be enumerable from the wire. The CallAdapter normalizes leading slashes, so services/schema accepts both forms. These are the only built-in operations; no admin operations are exposed through the call protocol itself.

Summary

Implemented services/list and services/schema built-in operations in registry/discovery.rs: spec constructors, factory handlers taking Arc<OperationRegistry>, JSON serialization of OperationSpec (incl. error_schemas per ADR-023), leading-slash normalization for services/schema, NOT_FOUND for unknown ops, INVALID_INPUT for missing name. Both registered as Local provenance with empty authority/env/caps and empty AccessControl. 13 new tests (106 total in call crate). Clippy clean. Merged to develop.