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

187 lines
7.1 KiB
Markdown

---
id: call/registry/service-discovery
name: Implement services/list and services/schema built-in operations
status: completed
depends_on: [call/registry/handler-registration]
scope: narrow
risk: low
impact: isolated
level: 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).
```json
{
"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 `operationId`s 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):
```rust
.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
```rust
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.
```rust
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.