Files
alknet/tasks/call/operation-context-forwarded-for.md
glm-5.2 df355c53a9 tasks: decompose ADR-029/030/031/032/034/035 source sync into 17 tasks
Decompose the source-to-spec sync for the core and call crates into atomic,
dependency-ordered tasks for implementation agents:

Core (7 tasks + review):
- peer-entry-model: PeerEntry struct, AuthPolicy.peers (ADR-030 keystone)
- credential-store-trait: CredentialStore/InMemoryCredentialStore/StoreError (ADR-031/035)
- identity-store-trait: IdentityStore async write trait (ADR-035)
- config-identity-provider-peerentry: ConfigIdentityProvider PeerEntry resolution (ADR-030)
- fingerprint-normalization: ed25519:hex for raw keys across quinn/iroh (ADR-030 §6)
- three-remote-roles-docs: document ADR-034 roles and verifier selection
- review-core-sync: phase gate before call consumes new identity semantics

Call (9 tasks + review):
- retire-remote-safe: remove ADR-028 machinery, AccessControl is the gate (ADR-029 §3)
- operation-context-forwarded-for: forwarded_for field, wire-ingress only (ADR-032)
- peer-composite-env: PeerCompositeEnv, PeerId=Identity.id, remove UUID (ADR-029/030)
- operation-env-invoke-peer: invoke_peer/peer_contains/PeerRef (ADR-029 §2)
- services-list-accesscontrol-filtered: AccessControl filter, list-peers opt-in (ADR-029 §6)
- call-client-verifier-selection: TLS client-auth, verifier by PeerEntry (OQ-29, ADR-034)
- from-call-forwarded-for: populate forwarded_for, peer-keyed registration (ADR-029 §5, ADR-032)
- dispatch-peer-identity: AccessControl::check(peer_identity), PeerId from resolution (ADR-029 §3, ADR-030 §5)
- review-call-sync: phase gate for the call sync

Validated: 58 tasks, no cycles, logical topo order, two review checkpoints.
2026-06-28 21:08:41 +00:00

5.3 KiB

id, name, status, depends_on, scope, risk, impact, level
id name status depends_on scope risk impact level
call/operation-context-forwarded-for Add forwarded_for field to OperationContext and wire from call.requested (ADR-032) pending
call/retire-remote-safe
narrow low component implementation

Description

Add the forwarded_for: Option<Identity> field to OperationContext and wire it from the call.requested payload. Per ADR-032, forwarded_for is metadata only — AccessControl::check never reads it; the ACL always authorizes the direct caller's identity. This is the wire-format one-way door folded into the OperationContext edit window (ADR-032 says this is the cheapest point since OperationContext is under edit for the ADR-029 migration).

OperationContext change

pub struct OperationContext {
    // ... existing fields ...
    pub forwarded_for: Option<Identity>,  // NEW (ADR-032)
}
  • forwarded_for: The original caller when this call was forwarded by a from_call handler (ADR-032). Metadata onlyAccessControl::check never reads it; the ACL always authorizes identity (the direct caller). Handlers may read it for logging, auditing, per-user rate limiting, or application context. The forwarder's claim, not a verified identity — a malicious hub can lie (same property as HTTP X-Forwarded-For).

Wire format (call.requested payload)

The call.requested payload already carries operationId, input, and optional auth_token. Add optional forwarded_for:

{
  "operationId": "/docker/start",
  "input": { ... },
  "auth_token": "alk_...",
  "forwarded_for": {           // optional (ADR-032)
    "id": "alice",
    "scopes": ["fs:read", "docker:start"],
    "resources": {}
  }
}

The payload is a serde_json::Value (not a typed struct), so forwarded_for is read from payload.get("forwarded_for") and deserialized into Identity.

build_root_context wiring

Dispatcher::build_root_context reads forwarded_for from the payload and populates the field:

fn build_root_context(
    &self,
    request_id: String,
    operation_name: &str,
    identity: Option<Identity>,
    forwarded_for: Option<Identity>,  // NEW parameter
    connection: &CallConnection,
) -> OperationContext {
    // ...
    OperationContext {
        // ...
        forwarded_for,  // from call.requested.forwarded_for
        // ...
    }
}

dispatch_requested extracts forwarded_for from the payload:

let forwarded_for = payload.get("forwarded_for")
    .and_then(|v| serde_json::from_value::<Identity>(v.clone()).ok());

OperationEnv::invoke sets None for composed children

forwarded_for is wire-ingress only (ADR-032 Assumption 3). Composed children (calls via OperationEnv::invoke) do NOT inherit forwarded_for — they get None. The field is populated from call.requested.forwarded_for by the dispatch path, and the from_call forwarding handler sets it when constructing the forwarded payload (that's call/from-call-forwarded-for).

In LocalOperationEnv::invoke_with_policy (and PeerCompositeEnv when built):

let context = OperationContext {
    // ...
    forwarded_for: None,  // composed children do not inherit (ADR-032)
    // ...
};

AccessControl::check never reads forwarded_for

The security property is structural: AccessControl::check takes Option<&Identity> (the direct caller's identity). The forwarded_for field is Option<Identity> on OperationContext, but the check signature doesn't accept it. Verify this invariant holds — no code path passes forwarded_for to AccessControl::check.

Acceptance Criteria

  • OperationContext.forwarded_for: Option<Identity> field added
  • build_root_context accepts forwarded_for parameter and populates the field
  • dispatch_requested extracts forwarded_for from payload.get("forwarded_for")
  • forwarded_for deserialized from JSON { id, scopes, resources } into Identity
  • OperationEnv::invoke_with_policy sets forwarded_for: None for composed children
  • AccessControl::check never reads forwarded_for (verify no code path passes it)
  • Missing forwarded_for in payload → None (no error)
  • Unit test: forwarded_for populated from payload
  • Unit test: missing forwarded_for → None
  • Unit test: composed children get forwarded_for: None
  • Unit test: AccessControl::check still uses identity (not forwarded_for)
  • cargo test -p alknet-call succeeds
  • cargo clippy -p alknet-call succeeds with no warnings

References

  • docs/architecture/crates/call/operation-registry.md — OperationContext.forwarded_for
  • docs/architecture/crates/call/call-protocol.md — call.requested payload, build_root_context
  • docs/architecture/decisions/032-forwarded-for-identity.md — ADR-032

Notes

forwarded_for is a wire-format one-way door (ADR-032) — folded into the OperationContext edit window because ADR-029 is already rewriting the composition env. The field is metadata only: AccessControl::check never reads it; the ACL always authorizes the direct caller's identity. The from_call handler populates it when forwarding (that's call/from-call-forwarded-for). Composed children get None (wire-ingress only, not composition-ingress).

Summary

To be filled on completion