187 lines
7.1 KiB
Markdown
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. |