{
  "schemaVersion": "1.0",
  "title": "Build a Trusted Local Module",
  "description": "Use this guide to compile one reviewed, first-party capability into the same process as aipd. You will pair a context-aware action handler with an exact manifest contribution. You will then register its factory before startup and verify adm",
  "canonical": "https://getaip.org/docs/guides/trusted-local-modules",
  "route": "/docs/guides/trusted-local-modules",
  "source": "docs/guides/trusted-local-modules.md",
  "protocol": "Agent Interoperability Protocol",
  "protocolVersion": "1.0",
  "section": "Build with AIP",
  "documentType": "Guide",
  "language": "en",
  "downloads": {
    "md": "/docs/download/guides/trusted-local-modules.md",
    "txt": "/docs/download/guides/trusted-local-modules.txt",
    "json": "/docs/download/guides/trusted-local-modules.json",
    "pdf": "/docs/download/guides/trusted-local-modules.pdf"
  },
  "content": {
    "format": "text/markdown",
    "markdown": "---\ntitle: Build a Trusted Local Module\ndescription: Add one bounded first-party action handler to the frozen aipd startup composition and verify its admission\nkind: how-to\naudience: developer\nappliesTo: \"1.x\"\nwritingStandard: \"aip-docs/1.0\"\nlastReviewedRevision: \"97be86e9efedf07ecf1783b03800f683f107fb04\"\n---\n\n# Build a Trusted Local Module\n\nUse this guide to compile one reviewed, first-party capability into the same\nprocess as `aipd`. You will pair a context-aware action handler with an exact\nmanifest contribution. You will then register its factory before startup and\nverify admission before the listener becomes available.\n\nA trusted local module is a deployment composition boundary. It is not a\nruntime plugin system. Adding, removing, or changing a module requires a new\napplication build and deployment. The module shares the daemon's process,\nruntime stores, resource limits, and failure domain.\n\nThe examples on this page target source revision\n`97be86e9efedf07ecf1783b03800f683f107fb04`. This authoring pass checked the\nsource and snippet syntax without building the workspace or starting a service.\n\n## Choose the local boundary deliberately\n\nUse a local module only when all of these statements are true:\n\n- the implementation is reviewed and operated as first-party daemon code;\n- the module can use the daemon's release, startup, and rollback lifecycle;\n- sharing one process and runtime-store handles is an accepted trust boundary;\n- the complete contribution can be prepared within a bounded startup timeout;\n- one invalid manifest, handler set, or route claim should fail startup.\n\nUse the remote connector fleet when an implementation needs independent\nrollout, scaling, isolation, provider-specific ownership, or long-tail catalog\ngrowth. Local placement does not make an implementation safer, qualified, or\nproduction-ready. It only changes process composition and dispatch placement.\n\n## Prerequisites\n\nYou need:\n\n- Rust `1.88` or newer and edition 2024 support;\n- access to the reviewed source revision or an approved immutable mirror;\n- an application that owns `AipDaemonDeployment` construction;\n- a reviewed capability contract and JSON Schemas for its input and output;\n- a decision that the module is required or optional during preparation;\n- deployment-owned identity, approval, storage, and transport configuration.\n\nThis guide uses a pure label normalizer. It contacts no provider, stores no\nsecret, and performs no external mutation. Production trust and storage setup\nremain separate deployment concerns.\n\n## 1. Pin the direct dependencies\n\nCreate a binary project and pin every AIP crate to the same source revision:\n\n```toml\n[package]\nname = \"aipd-local-module\"\nversion = \"0.1.0\"\nedition = \"2024\"\nrust-version = \"1.88\"\n\n[dependencies]\naip-core = { git = \"https://github.com/getaip/core\", rev = \"97be86e9efedf07ecf1783b03800f683f107fb04\" }\naip-discovery = { git = \"https://github.com/getaip/core\", rev = \"97be86e9efedf07ecf1783b03800f683f107fb04\" }\naip-runtime = { git = \"https://github.com/getaip/core\", rev = \"97be86e9efedf07ecf1783b03800f683f107fb04\" }\naipd = { git = \"https://github.com/getaip/core\", rev = \"97be86e9efedf07ecf1783b03800f683f107fb04\" }\nasync-trait = \"0.1\"\nserde = { version = \"1\", features = [\"derive\"] }\nserde_json = \"1\"\ntokio = { version = \"1\", features = [\"macros\", \"rt-multi-thread\"] }\n```\n\nEvery AIP workspace package has `publish = false` at the reviewed revision.\nDo not replace these entries with an assumed registry version. An approved\nvendored checkout may use path dependencies, but its revision must be recorded\noutside `Cargo.lock` through the deployment or SBOM process.\n\n## 2. Implement the handler and module factory\n\nCreate `src/main.rs` with one complete module. The handler rejects the\ncontext-free compatibility method and accepts only the runtime-built\n`ActionExecutionContext` path:\n\n```rust\nuse aip_core::{\n    Action, ActionResult, ActionResultStatus, Binding, Capability, CapabilityContract,\n    CapabilityId, CapabilityKind, CompensationContract, CompensationMode, DataContract,\n    DataSensitivity, ExecutionContract, ExpectedCompletionMode, IdempotencyCollisionBehavior,\n    IdempotencyContract, IdempotencyKeyScope, IdempotencyRequirement, Manifest, MessagePart,\n    Principal, PrincipalKind, ProfileId, RetrySafety, RiskLevel, ServiceLevelContract,\n    SideEffect, Stability,\n};\nuse aip_discovery::CapabilityImplementationSupport;\nuse aip_runtime::{\n    ActionExecutionContext, ActionHandler, RuntimeError, RuntimeResult,\n};\nuse aipd::{\n    AipDaemon, AipDaemonConfig, AipDaemonDeployment, DaemonModuleError,\n    DaemonModuleFactory, DaemonServices, LocalModuleDescriptor, PreparedDaemonModule,\n};\nuse async_trait::async_trait;\nuse serde::Deserialize;\nuse serde_json::json;\nuse std::{collections::BTreeSet, error::Error, sync::Arc};\n\nconst MODULE_ID: &str = \"label-normalizer\";\nconst CAPABILITY_ID: &str = \"cap:example:label.normalize\";\nconst NATIVE_HTTP_PROFILE: &str = \"aip.native.http.v1\";\n\n#[derive(Clone)]\nstruct LabelNormalizer;\n\n#[derive(Deserialize)]\n#[serde(deny_unknown_fields)]\nstruct NormalizeInput {\n    label: String,\n}\n\n#[async_trait]\nimpl ActionHandler for LabelNormalizer {\n    fn implementation_support(&self) -> CapabilityImplementationSupport {\n        CapabilityImplementationSupport {\n            invocation: true,\n            retry: true,\n            ..CapabilityImplementationSupport::default()\n        }\n    }\n\n    async fn handle(&self, _action: Action) -> RuntimeResult<ActionResult> {\n        Err(RuntimeError::Authorization(\n            \"label normalizer requires trusted execution context\".to_owned(),\n        ))\n    }\n\n    async fn handle_with_context(\n        &self,\n        action: Action,\n        context: ActionExecutionContext,\n    ) -> RuntimeResult<ActionResult> {\n        context\n            .actor\n            .validate(&BTreeSet::new())\n            .map_err(|error| RuntimeError::Authorization(error.to_string()))?;\n\n        let input: NormalizeInput = serde_json::from_value(action.input.clone())\n            .map_err(|error| RuntimeError::Handler(error.to_string()))?;\n        let normalized = input.label.split_whitespace().collect::<Vec<_>>().join(\" \");\n        if normalized.is_empty() {\n            return Err(RuntimeError::Handler(\n                \"label must contain a non-whitespace character\".to_owned(),\n            ));\n        }\n\n        Ok(ActionResult {\n            action_id: action.id,\n            status: ActionResultStatus::Completed,\n            output: Some(json!({ \"normalized\": normalized })),\n            message: vec![MessagePart::text(\"label normalized\")],\n            memory_update: None,\n            usage: None,\n            receipt: None,\n            error: None,\n        })\n    }\n}\n\nfn normalize_capability() -> Capability {\n    Capability {\n        id: CapabilityId::trusted(CAPABILITY_ID),\n        name: \"Normalize a label\".to_owned(),\n        kind: CapabilityKind::Tool,\n        input_schema: json!({\n            \"type\": \"object\",\n            \"additionalProperties\": false,\n            \"required\": [\"label\"],\n            \"properties\": {\n                \"label\": { \"type\": \"string\", \"minLength\": 1, \"maxLength\": 256 }\n            }\n        }),\n        output_schema: Some(json!({\n            \"type\": \"object\",\n            \"additionalProperties\": false,\n            \"required\": [\"normalized\"],\n            \"properties\": {\n                \"normalized\": { \"type\": \"string\", \"minLength\": 1, \"maxLength\": 256 }\n            }\n        })),\n        description: Some(\"Collapses whitespace in one label.\".to_owned()),\n        risk: Some(RiskLevel::Low),\n        stability: Some(Stability::Stable),\n        cost: None,\n        auth: None,\n        bindings: vec![Binding {\n            profile: ProfileId::from(NATIVE_HTTP_PROFILE),\n            metadata: [\n                (\"method\".to_owned(), json!(\"POST\")),\n                (\"path\".to_owned(), json!(\"/aip/v1/messages\")),\n                (\n                    \"message_type\".to_owned(),\n                    json!(\"aip.core.v1.action\"),\n                ),\n            ]\n            .into_iter()\n            .collect(),\n        }],\n        requires_human_approval: Some(false),\n        contract: Some(CapabilityContract {\n            side_effects: vec![SideEffect::Read],\n            idempotency: IdempotencyContract {\n                requirement: IdempotencyRequirement::Optional,\n                collision_behavior: IdempotencyCollisionBehavior::ReturnOriginalResult,\n                key_scope: IdempotencyKeyScope::Capability,\n                ttl_ms: None,\n            },\n            execution: ExecutionContract {\n                supports_sync: true,\n                supports_async: false,\n                supports_streaming: false,\n                supports_cancel: false,\n                supports_retry: true,\n                expected_completion: ExpectedCompletionMode::Sync,\n                retry_safety: RetrySafety::Safe,\n            },\n            data: DataContract {\n                sensitivity: DataSensitivity::Internal,\n                contains_pii: false,\n                redaction_required: false,\n                residency: None,\n                retention: None,\n            },\n            credentials: None,\n            approval: None,\n            sla: Some(ServiceLevelContract {\n                expected_latency_ms: Some(10),\n                timeout_ms: Some(1_000),\n                async_expected: false,\n                max_queue_delay_ms: Some(100),\n                availability_target: None,\n            }),\n            transaction: None,\n            compensation: Some(CompensationContract {\n                mode: CompensationMode::NotRequired,\n                compensation_capability_id: None,\n                compensation_window_ms: None,\n                requires_approval: false,\n            }),\n        }),\n    }\n}\n\n#[derive(Clone)]\nstruct LabelNormalizerModule;\n\n#[async_trait]\nimpl DaemonModuleFactory for LabelNormalizerModule {\n    fn descriptor(&self) -> LocalModuleDescriptor {\n        LocalModuleDescriptor::required_static(MODULE_ID)\n    }\n\n    async fn prepare(\n        &self,\n        services: &DaemonServices,\n    ) -> Result<PreparedDaemonModule, DaemonModuleError> {\n        let capability = normalize_capability();\n        let manifest = Manifest {\n            manifest_version: \"aip-manifest/v1\".to_owned(),\n            agent: Principal::new(services.service_id().clone(), PrincipalKind::Agent),\n            capabilities: vec![capability.clone()],\n            profiles: vec![ProfileId::from(NATIVE_HTTP_PROFILE)],\n            resources: Vec::new(),\n            channels: Vec::new(),\n            security: None,\n            governance: None,\n            limits: None,\n            compatibility: None,\n            extensions: None,\n        };\n\n        PreparedDaemonModule::new(self.descriptor(), manifest)\n            .with_handler(capability, Arc::new(LabelNormalizer))\n    }\n}\n\n#[tokio::main]\nasync fn main() -> Result<(), Box<dyn Error>> {\n    let config = AipDaemonConfig::default();\n    let bind = config.bind;\n    let deployment = AipDaemonDeployment::default()\n        .with_module_factory(LabelNormalizerModule);\n    let daemon = AipDaemon::new_with_deployment(config, deployment).await?;\n\n    let admitted = daemon\n        .manifest()\n        .capabilities\n        .iter()\n        .any(|capability| capability.id.as_str() == CAPABILITY_ID);\n    if !admitted {\n        return Err(std::io::Error::other(\"local capability was not admitted\").into());\n    }\n\n    daemon.serve(bind).await?;\n    Ok(())\n}\n```\n\nThe descriptor is static and performs no I/O. `prepare` receives only the\ndaemon service principal and cloned runtime-store handles. It does not receive\nthe gateway router, active-action map, callback dispatcher, or policy engine.\n\nThe handler's implementation support must match its capability contract.\nInvocation and safe retry are enabled here. Cancellation, streaming,\ntransactions, reconciliation, compensation, approval, and credential\nresolution remain disabled.\n\n## 3. Keep the manifest and handler set exact\n\n`PreparedDaemonModule::with_handler` records a handler by capability ID. The\ndaemon later compares the complete executable capability set with the complete\nhandler set. Every non-resource capability needs exactly one handler, and a\nresource capability cannot receive an action handler.\n\nThe module manifest contributes capabilities, profiles, resources, and\nchannels to the daemon manifest. The daemon retains its own security,\ngovernance, limits, and principal configuration. It also records a bounded\nmodule summary under the combined manifest's connector extensions.\n\nDo not treat a module manifest as a way to override daemon security. Configure\nidentity resolvers, approval authority, storage, callback policy, and transports\nin the deployment that owns `AipDaemonConfig` and `AipDaemonDeployment`.\n\n## 4. Register the factory before daemon construction\n\n`with_module_factory` adds the concrete factory to the frozen deployment\ncomposition. `AipDaemon::new_with_deployment` prepares and admits every factory\nbefore it returns a daemon. `serve` binds the listener only after that\nconstructor has succeeded.\n\nThe example uses default daemon security solely to keep module composition\nvisible. Default native AIP requests still require signed envelopes and trusted\nsigner configuration. Add production trust and durable storage through the\nowning deployment; do not weaken authentication to test the module.\n\nGenerate the lockfile, compile the application, and start it in the intended\ntest environment:\n\n```sh\ncargo generate-lockfile\ncargo check --locked\ncargo run --locked\n```\n\nTreat these commands as instructions for the consumer project. They were not\nexecuted during this source-only documentation pass.\n\n## 5. Verify admission and readiness\n\nIn another terminal, query the native manifest and readiness endpoint:\n\n```sh\ncurl -fsS http://127.0.0.1:8080/aip/v1/manifest \\\n  | jq -e '.capabilities[] | select(.id == \"cap:example:label.normalize\")'\n\ncurl -fsS http://127.0.0.1:8080/ready \\\n  | jq -e '.modules[\"label-normalizer\"] == {\n      \"required\": true,\n      \"ready\": true,\n      \"detail\": \"module prepared and admitted\"\n    }'\n```\n\nBoth commands must exit with status `0`. A `ready: true` module status means\nthat preparation and admission succeeded. It does not prove provider health,\nexternal side effects, durability, conformance, qualification, or production\nreadiness.\n\nIf the module registers a readiness connector, gateway readiness evaluates that\nconnector separately. Module admission status is not a substitute for its\nreadiness result.\n\n## 6. Understand the startup decision\n\nThe daemon applies this sequence before publishing the combined manifest:\n\n1. reject more than 64 factories;\n2. validate every static ID and timeout;\n3. sort factories by module ID and reject duplicate IDs;\n4. run each `prepare` future under its declared timeout;\n5. require the returned descriptor to equal the static descriptor;\n6. validate manifest size and exact handler coverage;\n7. reject capability, connector, and HTTP-route ownership conflicts;\n8. merge accepted contributions and construct the gateway atomically;\n9. register readiness connectors and delegation routers;\n10. bind the HTTP listener only when application code calls `serve`.\n\nRequired and optional descriptors differ only at step 4. An error or timeout\nfrom `prepare` stops startup for a required module. The same result skips an\noptional module and records `required: false, ready: false` in `/ready`.\n\nOptional does not suppress an invalid ID, invalid timeout, changed descriptor,\ninvalid manifest, handler mismatch, or ownership conflict. Those errors still\nstop daemon construction because the composition cannot be admitted safely.\n\n## 7. Stay within the module limits\n\nThe reviewed implementation applies these hard bounds:\n\n| Boundary | Limit | Design consequence |\n|---|---:|---|\n| Modules per daemon | 64 | Prefer the remote fleet for a growing connector catalog |\n| Module ID | 1–64 characters | Use lower-case ASCII letters, digits, `.`, `_`, or `-` |\n| Preparation timeout | 1–300,000 ms | Default is 30,000 ms; keep startup work bounded |\n| Serialized manifest | 4 MiB per module | Keep product catalogs and large generated data outside the contribution |\n| Executable handlers | 4,096 per module | Every callable capability still needs one exact handler |\n| HTTP route claims | 256 per mount | Declare each method and path for conflict detection |\n| HTTP path | 512 characters | Keep every module route below `/connectors/` |\n| Error detail created with `DaemonModuleError::new` | 1,024 characters | Never place credentials or provider payloads in the message |\n\n`DaemonModuleError::new` truncates its message, but truncation is not redaction.\nReturn a stable error code and a bounded operational description without secret\nmaterial.\n\n## 8. Add optional contributions only when owned\n\nA prepared module can contribute more than action handlers:\n\n| Contribution | API | Ownership requirement |\n|---|---|---|\n| Runtime-store access during preparation | `DaemonServices::runtime_stores` | Use only stores owned by the daemon lifecycle |\n| Readiness connector | `with_connector_arc` | Use a stable, unique connector ID and bounded readiness checks |\n| Native delegation router | `with_delegation_router_arc` | Return ownership only for the remote delegations the module can route |\n| State-complete HTTP router | `with_http_mount` | Declare every method and path, all under `/connectors/` |\n\nAn HTTP mount carries both an Axum router and explicit route claims. The daemon\nuses the claims for deterministic conflict checks. Keep the claims identical to\nthe router's actual routes; the constructor does not infer them from Axum.\n\nDo not use a local HTTP mount to claim a core route such as\n`/aip/v1/messages`. Module-owned routes are restricted to `/connectors/`.\n\n## 9. Review the trust boundary\n\nBefore accepting a module, confirm all of the following:\n\n- maintainers review it as part of the daemon artifact;\n- `descriptor` is deterministic and never contacts a provider;\n- `prepare` is bounded, cancellation-safe, and contains no unbounded discovery;\n- every handler uses `handle_with_context` for trusted runtime state;\n- a trust-dependent handler rejects the context-free `handle` path;\n- identity comes from `context.actor`, never from action input;\n- tenant, credential, approval, transaction, and redaction data come only from\n  their matching context fields;\n- logs and errors omit action payloads, credentials, and provider responses;\n- manifest contracts describe only behavior the handler implements;\n- module and remote fleet implementations never claim the same capability ID;\n- the deployment accepts same-process memory, CPU, store, and crash exposure.\n\n`ActionExecutionContext` is deliberately non-serializable. It contains the\ntransport-authenticated actor, optional verified tenant, opaque credential\nhandle, deadline, cancellation token, idempotency reservation, and approval.\nIt also carries transaction state, checkpoint publishers, a stream, trusted\ntrace identifiers, and the redaction policy. Do not reconstruct it from an AIP\npayload.\n\n## Resolve common failures\n\n| Symptom or code | Meaning | Bounded action |\n|---|---|---|\n| `aipd.module.invalid_id` | The module ID violates the static format | Fix the compile-time ID; do not normalize it silently |\n| `aipd.module.invalid_timeout` | The timeout is zero or exceeds five minutes | Choose a bounded value within the supported range |\n| `aipd.module.duplicate_id` | Two factories own the same module ID | Remove one owner or assign a stable distinct ID |\n| `aipd.module.startup_timeout` | Preparation exceeded its descriptor timeout | Bound the preparation work; inspect whether the module must remain required |\n| `aipd.module.descriptor_changed` | `prepare` returned a different descriptor | Return the exact static descriptor from the prepared module |\n| `aipd.module.manifest_limit` | The serialized contribution exceeds 4 MiB | Move generated or tenant-specific catalog data out of the local manifest |\n| `aipd.module.handler_manifest_mismatch` | Callable capabilities and handlers differ | Add the exact missing handler or remove the undeclared handler |\n| `aipd.module.capability_conflict` | Core or another module owns the capability ID | Preserve one canonical owner and change the manifest before retrying startup |\n| `aipd.module.connector_conflict` | Two modules return the same readiness connector ID | Preserve one connector owner or assign a stable distinct ID |\n| `aipd.module.reserved_http_route` | A module route is outside `/connectors/` | Move it under the module namespace; never shadow a core route |\n| `aipd.module.http_route_conflict` | Two modules claim the same method and path | Keep one owner and update both claims and router code together |\n| Daemon starts without an optional capability | Its `prepare` call failed or timed out | Inspect `/ready`, fix the bounded failure, and redeploy; do not invoke an undiscovered capability |\n\nPreserve the first startup error. Repeated restarts cannot repair a deterministic\nmanifest or ownership conflict and can hide the original evidence.\n\n## Roll back the compiled composition\n\nA local module cannot be unloaded or hot-swapped. To roll it back:\n\n1. stop routing new work to the affected daemon artifact;\n2. preserve the startup error and deployed source identity;\n3. remove the matching `.with_module_factory(...)` registration;\n4. rebuild from the last approved dependency and configuration set;\n5. deploy the replacement artifact through the normal rollout boundary;\n6. verify that the capability is absent from the manifest;\n7. verify that the old module ID is absent from `/ready`;\n8. reconcile any actions accepted before the old process stopped.\n\nRemoving the factory changes discovery after redeployment. It does not cancel,\nreverse, or settle work already persisted by the previous process.\n\n## Related documentation\n\n- Use [the Rust SDK](use-rust-sdk.md) to select and pin the required crate\n  boundaries.\n- Read [Capabilities and contracts](../concepts/capabilities.md) before defining\n  the manifest and handler support matrix.\n- Read [Actions and sessions](../concepts/actions-and-sessions.md) for durable\n  lifecycle, idempotency, cancellation, and unknown outcomes.\n- Read [Identity and trust](../concepts/identity-and-trust.md) before consuming\n  authenticated actor, tenant, credential, or approval context.\n- Read [Profiles, transports, and connectors](../concepts/profiles-and-connectors.md)\n  before choosing local placement instead of the remote fleet.\n",
    "text": "Build a Trusted Local Module\n\nUse this guide to compile one reviewed, first-party capability into the same\nprocess as aipd. You will pair a context-aware action handler with an exact\nmanifest contribution. You will then register its factory before startup and\nverify admission before the listener becomes available.\n\nA trusted local module is a deployment composition boundary. It is not a\nruntime plugin system. Adding, removing, or changing a module requires a new\napplication build and deployment. The module shares the daemon's process,\nruntime stores, resource limits, and failure domain.\n\nThe examples on this page target source revision\n97be86e9efedf07ecf1783b03800f683f107fb04. This authoring pass checked the\nsource and snippet syntax without building the workspace or starting a service.\n\nChoose the local boundary deliberately\n\nUse a local module only when all of these statements are true:\n• the implementation is reviewed and operated as first-party daemon code;\n• the module can use the daemon's release, startup, and rollback lifecycle;\n• sharing one process and runtime-store handles is an accepted trust boundary;\n• the complete contribution can be prepared within a bounded startup timeout;\n• one invalid manifest, handler set, or route claim should fail startup.\n\nUse the remote connector fleet when an implementation needs independent\nrollout, scaling, isolation, provider-specific ownership, or long-tail catalog\ngrowth. Local placement does not make an implementation safer, qualified, or\nproduction-ready. It only changes process composition and dispatch placement.\n\nPrerequisites\n\nYou need:\n• Rust 1.88 or newer and edition 2024 support;\n• access to the reviewed source revision or an approved immutable mirror;\n• an application that owns AipDaemonDeployment construction;\n• a reviewed capability contract and JSON Schemas for its input and output;\n• a decision that the module is required or optional during preparation;\n• deployment-owned identity, approval, storage, and transport configuration.\n\nThis guide uses a pure label normalizer. It contacts no provider, stores no\nsecret, and performs no external mutation. Production trust and storage setup\nremain separate deployment concerns.\n1. Pin the direct dependencies\n\nCreate a binary project and pin every AIP crate to the same source revision:\n\n[package]\nname = \"aipd-local-module\"\nversion = \"0.1.0\"\nedition = \"2024\"\nrust-version = \"1.88\"\n\n[dependencies]\naip-core = { git = \"https://github.com/getaip/core\", rev = \"97be86e9efedf07ecf1783b03800f683f107fb04\" }\naip-discovery = { git = \"https://github.com/getaip/core\", rev = \"97be86e9efedf07ecf1783b03800f683f107fb04\" }\naip-runtime = { git = \"https://github.com/getaip/core\", rev = \"97be86e9efedf07ecf1783b03800f683f107fb04\" }\naipd = { git = \"https://github.com/getaip/core\", rev = \"97be86e9efedf07ecf1783b03800f683f107fb04\" }\nasync-trait = \"0.1\"\nserde = { version = \"1\", features = [\"derive\"] }\nserdejson = \"1\"\ntokio = { version = \"1\", features = [\"macros\", \"rt-multi-thread\"] }\n\nEvery AIP workspace package has publish = false at the reviewed revision.\nDo not replace these entries with an assumed registry version. An approved\nvendored checkout may use path dependencies, but its revision must be recorded\noutside Cargo.lock through the deployment or SBOM process.\n2. Implement the handler and module factory\n\nCreate src/main.rs with one complete module. The handler rejects the\ncontext-free compatibility method and accepts only the runtime-built\nActionExecutionContext path:\n\nuse aipcore::{\n    Action, ActionResult, ActionResultStatus, Binding, Capability, CapabilityContract,\n    CapabilityId, CapabilityKind, CompensationContract, CompensationMode, DataContract,\n    DataSensitivity, ExecutionContract, ExpectedCompletionMode, IdempotencyCollisionBehavior,\n    IdempotencyContract, IdempotencyKeyScope, IdempotencyRequirement, Manifest, MessagePart,\n    Principal, PrincipalKind, ProfileId, RetrySafety, RiskLevel, ServiceLevelContract,\n    SideEffect, Stability,\n};\nuse aipdiscovery::CapabilityImplementationSupport;\nuse aipruntime::{\n    ActionExecutionContext, ActionHandler, RuntimeError, RuntimeResult,\n};\nuse aipd::{\n    AipDaemon, AipDaemonConfig, AipDaemonDeployment, DaemonModuleError,\n    DaemonModuleFactory, DaemonServices, LocalModuleDescriptor, PreparedDaemonModule,\n};\nuse asynctrait::asynctrait;\nuse serde::Deserialize;\nuse serdejson::json;\nuse std::{collections::BTreeSet, error::Error, sync::Arc};\n\nconst MODULEID: &str = \"label-normalizer\";\nconst CAPABILITYID: &str = \"cap:example:label.normalize\";\nconst NATIVEHTTPPROFILE: &str = \"aip.native.http.v1\";\n\n#[derive(Clone)]\nstruct LabelNormalizer;\n\n#[derive(Deserialize)]\n#[serde(denyunknownfields)]\nstruct NormalizeInput {\n    label: String,\n}\n\n#[asynctrait]\nimpl ActionHandler for LabelNormalizer {\n    fn implementationsupport(&self) -> CapabilityImplementationSupport {\n        CapabilityImplementationSupport {\n            invocation: true,\n            retry: true,\n            ..CapabilityImplementationSupport::default()\n        }\n    }\n\n    async fn handle(&self, action: Action) -> RuntimeResult {\n        Err(RuntimeError::Authorization(\n            \"label normalizer requires trusted execution context\".toowned(),\n        ))\n    }\n\n    async fn handlewithcontext(\n        &self,\n        action: Action,\n        context: ActionExecutionContext,\n    ) -> RuntimeResult {\n        context\n            .actor\n            .validate(&BTreeSet::new())\n            .maperr(|error| RuntimeError::Authorization(error.tostring()))?;\n\n        let input: NormalizeInput = serdejson::fromvalue(action.input.clone())\n            .maperr(|error| RuntimeError::Handler(error.tostring()))?;\n        let normalized = input.label.splitwhitespace().collect::>().join(\" \");\n        if normalized.isempty() {\n            return Err(RuntimeError::Handler(\n                \"label must contain a non-whitespace character\".toowned(),\n            ));\n        }\n\n        Ok(ActionResult {\n            actionid: action.id,\n            status: ActionResultStatus::Completed,\n            output: Some(json!({ \"normalized\": normalized })),\n            message: vec![MessagePart::text(\"label normalized\")],\n            memoryupdate: None,\n            usage: None,\n            receipt: None,\n            error: None,\n        })\n    }\n}\n\nfn normalizecapability() -> Capability {\n    Capability {\n        id: CapabilityId::trusted(CAPABILITYID),\n        name: \"Normalize a label\".toowned(),\n        kind: CapabilityKind::Tool,\n        inputschema: json!({\n            \"type\": \"object\",\n            \"additionalProperties\": false,\n            \"required\": [\"label\"],\n            \"properties\": {\n                \"label\": { \"type\": \"string\", \"minLength\": 1, \"maxLength\": 256 }\n            }\n        }),\n        outputschema: Some(json!({\n            \"type\": \"object\",\n            \"additionalProperties\": false,\n            \"required\": [\"normalized\"],\n            \"properties\": {\n                \"normalized\": { \"type\": \"string\", \"minLength\": 1, \"maxLength\": 256 }\n            }\n        })),\n        description: Some(\"Collapses whitespace in one label.\".toowned()),\n        risk: Some(RiskLevel::Low),\n        stability: Some(Stability::Stable),\n        cost: None,\n        auth: None,\n        bindings: vec![Binding {\n            profile: ProfileId::from(NATIVEHTTPPROFILE),\n            metadata: [\n                (\"method\".toowned(), json!(\"POST\")),\n                (\"path\".toowned(), json!(\"/aip/v1/messages\")),\n                (\n                    \"messagetype\".toowned(),\n                    json!(\"aip.core.v1.action\"),\n                ),\n            ]\n            .intoiter()\n            .collect(),\n        }],\n        requireshumanapproval: Some(false),\n        contract: Some(CapabilityContract {\n            sideeffects: vec![SideEffect::Read],\n            idempotency: IdempotencyContract {\n                requirement: IdempotencyRequirement::Optional,\n                collisionbehavior: IdempotencyCollisionBehavior::ReturnOriginalResult,\n                keyscope: IdempotencyKeyScope::Capability,\n                ttlms: None,\n            },\n            execution: ExecutionContract {\n                supportssync: true,\n                supportsasync: false,\n                supportsstreaming: false,\n                supportscancel: false,\n                supportsretry: true,\n                expectedcompletion: ExpectedCompletionMode::Sync,\n                retrysafety: RetrySafety::Safe,\n            },\n            data: DataContract {\n                sensitivity: DataSensitivity::Internal,\n                containspii: false,\n                redactionrequired: false,\n                residency: None,\n                retention: None,\n            },\n            credentials: None,\n            approval: None,\n            sla: Some(ServiceLevelContract {\n                expectedlatencyms: Some(10),\n                timeoutms: Some(1000),\n                asyncexpected: false,\n                maxqueuedelayms: Some(100),\n                availabilitytarget: None,\n            }),\n            transaction: None,\n            compensation: Some(CompensationContract {\n                mode: CompensationMode::NotRequired,\n                compensationcapabilityid: None,\n                compensationwindowms: None,\n                requiresapproval: false,\n            }),\n        }),\n    }\n}\n\n#[derive(Clone)]\nstruct LabelNormalizerModule;\n\n#[asynctrait]\nimpl DaemonModuleFactory for LabelNormalizerModule {\n    fn descriptor(&self) -> LocalModuleDescriptor {\n        LocalModuleDescriptor::requiredstatic(MODULEID)\n    }\n\n    async fn prepare(\n        &self,\n        services: &DaemonServices,\n    ) -> Result {\n        let capability = normalizecapability();\n        let manifest = Manifest {\n            manifestversion: \"aip-manifest/v1\".toowned(),\n            agent: Principal::new(services.serviceid().clone(), PrincipalKind::Agent),\n            capabilities: vec![capability.clone()],\n            profiles: vec![ProfileId::from(NATIVEHTTPPROFILE)],\n            resources: Vec::new(),\n            channels: Vec::new(),\n            security: None,\n            governance: None,\n            limits: None,\n            compatibility: None,\n            extensions: None,\n        };\n\n        PreparedDaemonModule::new(self.descriptor(), manifest)\n            .withhandler(capability, Arc::new(LabelNormalizer))\n    }\n}\n\n#[tokio::main]\nasync fn main() -> Result> {\n    let config = AipDaemonConfig::default();\n    let bind = config.bind;\n    let deployment = AipDaemonDeployment::default()\n        .withmodulefactory(LabelNormalizerModule);\n    let daemon = AipDaemon::newwithdeployment(config, deployment).await?;\n\n    let admitted = daemon\n        .manifest()\n        .capabilities\n        .iter()\n        .any(|capability| capability.id.asstr() == CAPABILITYID);\n    if !admitted {\n        return Err(std::io::Error::other(\"local capability was not admitted\").into());\n    }\n\n    daemon.serve(bind).await?;\n    Ok(())\n}\n\nThe descriptor is static and performs no I/O. prepare receives only the\ndaemon service principal and cloned runtime-store handles. It does not receive\nthe gateway router, active-action map, callback dispatcher, or policy engine.\n\nThe handler's implementation support must match its capability contract.\nInvocation and safe retry are enabled here. Cancellation, streaming,\ntransactions, reconciliation, compensation, approval, and credential\nresolution remain disabled.\n3. Keep the manifest and handler set exact\n\nPreparedDaemonModule::withhandler records a handler by capability ID. The\ndaemon later compares the complete executable capability set with the complete\nhandler set. Every non-resource capability needs exactly one handler, and a\nresource capability cannot receive an action handler.\n\nThe module manifest contributes capabilities, profiles, resources, and\nchannels to the daemon manifest. The daemon retains its own security,\ngovernance, limits, and principal configuration. It also records a bounded\nmodule summary under the combined manifest's connector extensions.\n\nDo not treat a module manifest as a way to override daemon security. Configure\nidentity resolvers, approval authority, storage, callback policy, and transports\nin the deployment that owns AipDaemonConfig and AipDaemonDeployment.\n4. Register the factory before daemon construction\n\nwithmodulefactory adds the concrete factory to the frozen deployment\ncomposition. AipDaemon::newwithdeployment prepares and admits every factory\nbefore it returns a daemon. serve binds the listener only after that\nconstructor has succeeded.\n\nThe example uses default daemon security solely to keep module composition\nvisible. Default native AIP requests still require signed envelopes and trusted\nsigner configuration. Add production trust and durable storage through the\nowning deployment; do not weaken authentication to test the module.\n\nGenerate the lockfile, compile the application, and start it in the intended\ntest environment:\n\ncargo generate-lockfile\ncargo check --locked\ncargo run --locked\n\nTreat these commands as instructions for the consumer project. They were not\nexecuted during this source-only documentation pass.\n5. Verify admission and readiness\n\nIn another terminal, query the native manifest and readiness endpoint:\n\ncurl -fsS http://127.0.0.1:8080/aip/v1/manifest \\\n  | jq -e '.capabilities[] | select(.id == \"cap:example:label.normalize\")'\n\ncurl -fsS http://127.0.0.1:8080/ready \\\n  | jq -e '.modules[\"label-normalizer\"] == {\n      \"required\": true,\n      \"ready\": true,\n      \"detail\": \"module prepared and admitted\"\n    }'\n\nBoth commands must exit with status 0. A ready: true module status means\nthat preparation and admission succeeded. It does not prove provider health,\nexternal side effects, durability, conformance, qualification, or production\nreadiness.\n\nIf the module registers a readiness connector, gateway readiness evaluates that\nconnector separately. Module admission status is not a substitute for its\nreadiness result.\n6. Understand the startup decision\n\nThe daemon applies this sequence before publishing the combined manifest:\n1. reject more than 64 factories;\n2. validate every static ID and timeout;\n3. sort factories by module ID and reject duplicate IDs;\n4. run each prepare future under its declared timeout;\n5. require the returned descriptor to equal the static descriptor;\n6. validate manifest size and exact handler coverage;\n7. reject capability, connector, and HTTP-route ownership conflicts;\n8. merge accepted contributions and construct the gateway atomically;\n9. register readiness connectors and delegation routers;\n10. bind the HTTP listener only when application code calls serve.\n\nRequired and optional descriptors differ only at step 4. An error or timeout\nfrom prepare stops startup for a required module. The same result skips an\noptional module and records required: false, ready: false in /ready.\n\nOptional does not suppress an invalid ID, invalid timeout, changed descriptor,\ninvalid manifest, handler mismatch, or ownership conflict. Those errors still\nstop daemon construction because the composition cannot be admitted safely.\n7. Stay within the module limits\n\nThe reviewed implementation applies these hard bounds:\n\n| Boundary | Limit | Design consequence |\n\n| Modules per daemon | 64 | Prefer the remote fleet for a growing connector catalog |\n| Module ID | 1–64 characters | Use lower-case ASCII letters, digits, ., , or - |\n| Preparation timeout | 1–300,000 ms | Default is 30,000 ms; keep startup work bounded |\n| Serialized manifest | 4 MiB per module | Keep product catalogs and large generated data outside the contribution |\n| Executable handlers | 4,096 per module | Every callable capability still needs one exact handler |\n| HTTP route claims | 256 per mount | Declare each method and path for conflict detection |\n| HTTP path | 512 characters | Keep every module route below /connectors/ |\n| Error detail created with DaemonModuleError::new | 1,024 characters | Never place credentials or provider payloads in the message |\n\nDaemonModuleError::new truncates its message, but truncation is not redaction.\nReturn a stable error code and a bounded operational description without secret\nmaterial.\n8. Add optional contributions only when owned\n\nA prepared module can contribute more than action handlers:\n\n| Contribution | API | Ownership requirement |\n\n| Runtime-store access during preparation | DaemonServices::runtimestores | Use only stores owned by the daemon lifecycle |\n| Readiness connector | withconnectorarc | Use a stable, unique connector ID and bounded readiness checks |\n| Native delegation router | withdelegationrouterarc | Return ownership only for the remote delegations the module can route |\n| State-complete HTTP router | withhttpmount | Declare every method and path, all under /connectors/ |\n\nAn HTTP mount carries both an Axum router and explicit route claims. The daemon\nuses the claims for deterministic conflict checks. Keep the claims identical to\nthe router's actual routes; the constructor does not infer them from Axum.\n\nDo not use a local HTTP mount to claim a core route such as\n/aip/v1/messages. Module-owned routes are restricted to /connectors/.\n9. Review the trust boundary\n\nBefore accepting a module, confirm all of the following:\n• maintainers review it as part of the daemon artifact;\n• descriptor is deterministic and never contacts a provider;\n• prepare is bounded, cancellation-safe, and contains no unbounded discovery;\n• every handler uses handlewithcontext for trusted runtime state;\n• a trust-dependent handler rejects the context-free handle path;\n• identity comes from context.actor, never from action input;\n• tenant, credential, approval, transaction, and redaction data come only from\n  their matching context fields;\n• logs and errors omit action payloads, credentials, and provider responses;\n• manifest contracts describe only behavior the handler implements;\n• module and remote fleet implementations never claim the same capability ID;\n• the deployment accepts same-process memory, CPU, store, and crash exposure.\n\nActionExecutionContext is deliberately non-serializable. It contains the\ntransport-authenticated actor, optional verified tenant, opaque credential\nhandle, deadline, cancellation token, idempotency reservation, and approval.\nIt also carries transaction state, checkpoint publishers, a stream, trusted\ntrace identifiers, and the redaction policy. Do not reconstruct it from an AIP\npayload.\n\nResolve common failures\n\n| Symptom or code | Meaning | Bounded action |\n\n| aipd.module.invalidid | The module ID violates the static format | Fix the compile-time ID; do not normalize it silently |\n| aipd.module.invalidtimeout | The timeout is zero or exceeds five minutes | Choose a bounded value within the supported range |\n| aipd.module.duplicateid | Two factories own the same module ID | Remove one owner or assign a stable distinct ID |\n| aipd.module.startuptimeout | Preparation exceeded its descriptor timeout | Bound the preparation work; inspect whether the module must remain required |\n| aipd.module.descriptorchanged | prepare returned a different descriptor | Return the exact static descriptor from the prepared module |\n| aipd.module.manifestlimit | The serialized contribution exceeds 4 MiB | Move generated or tenant-specific catalog data out of the local manifest |\n| aipd.module.handlermanifestmismatch | Callable capabilities and handlers differ | Add the exact missing handler or remove the undeclared handler |\n| aipd.module.capabilityconflict | Core or another module owns the capability ID | Preserve one canonical owner and change the manifest before retrying startup |\n| aipd.module.connectorconflict | Two modules return the same readiness connector ID | Preserve one connector owner or assign a stable distinct ID |\n| aipd.module.reservedhttproute | A module route is outside /connectors/ | Move it under the module namespace; never shadow a core route |\n| aipd.module.httprouteconflict | Two modules claim the same method and path | Keep one owner and update both claims and router code together |\n| Daemon starts without an optional capability | Its prepare call failed or timed out | Inspect /ready, fix the bounded failure, and redeploy; do not invoke an undiscovered capability |\n\nPreserve the first startup error. Repeated restarts cannot repair a deterministic\nmanifest or ownership conflict and can hide the original evidence.\n\nRoll back the compiled composition\n\nA local module cannot be unloaded or hot-swapped. To roll it back:\n1. stop routing new work to the affected daemon artifact;\n2. preserve the startup error and deployed source identity;\n3. remove the matching .withmodulefactory(...) registration;\n4. rebuild from the last approved dependency and configuration set;\n5. deploy the replacement artifact through the normal rollout boundary;\n6. verify that the capability is absent from the manifest;\n7. verify that the old module ID is absent from /ready;\n8. reconcile any actions accepted before the old process stopped.\n\nRemoving the factory changes discovery after redeployment. It does not cancel,\nreverse, or settle work already persisted by the previous process.\n\nRelated documentation\n• Use the Rust SDK (use-rust-sdk.md) to select and pin the required crate\n  boundaries.\n• Read Capabilities and contracts (../concepts/capabilities.md) before defining\n  the manifest and handler support matrix.\n• Read Actions and sessions (../concepts/actions-and-sessions.md) for durable\n  lifecycle, idempotency, cancellation, and unknown outcomes.\n• Read Identity and trust (../concepts/identity-and-trust.md) before consuming\n  authenticated actor, tenant, credential, or approval context.\n• Read Profiles, transports, and connectors (../concepts/profiles-and-connectors.md)\n  before choosing local placement instead of the remote fleet.\n"
  },
  "integrity": {
    "algorithm": "sha256",
    "sourceDigest": "b9b4e591e27bcbc4b18ad05004a647540c4a610b51521fc087730f86cbf2f286"
  }
}
